개발자 끄적끄적

스레드 동기화 본문

운영체제

스레드 동기화

햏치 2024. 4. 13. 19:20

<병행 프로세스(Concurrent Process)>
- 프로세스 여러 개가 동시에 실행되는 것
  - 독립적인 프로세스(Independent process)
    - 다른 프로세스 실행에 영향을 주거나 받지 않는 프로세스

  - 협력적인 프로세스(Cooperating process)
    - 다른 프로세스 실행에 영향을 주거나 받는 프로세스
      - 정보 공유
      - 계산 속도 향상
      - 모듈식 시스템 구성
      - 사용자 편의
 
    - 제한된 자원을 공유하기 위해 자주 상호작용 한다
      - 상호작용하는 프로세스는 순서에 맞게 실행되도록 동기화 시켜야한다




<동기화의 필요성>
- 협력적인 스레드들간에
  - 공유 데이터를 동시에(concurrently) 조작하면 데이터 불일치(data inconsistency)가 생길 수 있다
    - 데이터 훼손 발생

- 데이터 일치(data consistency)를 위해서는
  - 협력적인 스레드들이 공유 데이터를 순서적으로 접근하는 방법이 필요
    - 공유 데이터는 한 스레드씩 배타적/독점적 접근 필요

- 공유 집계판의 동시 접근 문제





<공유 집계판 사례의 코드 모델>
- 두 학생 : 스레드 T1, T2
- 공유 집계판 : 공유 변수 sum
- 처리할 계산 : sum = sum + 10;
- ex)
mov ax, [sum] //sum 변수 값을 읽어 ax 레지스터에 저장
add ax, 10; //ax 레지스터 값 10 증가
mov[sum], ax; //ax 레지스터 값을 sum 변수에 저장
==(동일)
sum = sum+10;






<경쟁 상태(race condition)>
- 여러 스레드가 공유 변수에 동시에(concurrently)접근하는 상황
  - 공유 데이터의 결과를 보장할 수 없는 상황 
    - 공유 데이터가 훼손 될 수도 있다

   - 멀티 스레딩, 커널 코드, 다중 코에에서

- '협력적인' 스레드들을 동기화(synchronized)
  - 한 스레드가 접근 마칠 때까지
  - 다른 스레드의 접근을 막도록 제어




<임계 영역 문제(Critical-Section Problem)>
- 공유 데이터를 사용하기 위해 경쟁하는 n개의 스레드들 가정
  - 임계 영역(Critical Section)
    - 공유 데이터를 조작하는 명령을 포함하는 코드 영역
      - 공유 데이터는 둘 이상의 스레드가 동시에 접근하면 안된다

- 한 스레드가 임계 영역을 수행 중이면
  - 다른 스레드는 자신의 임계 영역의 코드를 수행할 수 없어야한다
    - 한 번에 하나의 스레드만 공유 데이터에 접근 위해





<상호 배제(mutual exclusion)>
- 임계영역을 한 스레드만 배타적, 독점적으로 사용하도록 하는 기법
  - 임계영역에 먼저 진입한 스레드가 임계영역의 실행을 끝마칠 때까지 다른 스레드가 '진입하지' 못하도록 보장





<멀티 스레드 프로그램의 일반적인 구성>
일반 코드(non-critical code)
entry 코드(임계구역 진입코드) //임계구역의 상호배제를 위한 코드
--------------------------------------------------------------------
임계구역(critical code)
--------------------------------------------------------------------
exit 코드(임계구역 진출코드) //임계구역의 상호배제를 위한 코드
일반코드(non-critical code)





<상호 배제를 포함하는 프로그램의 구성>
- 일반 코드(non-critical code)
  - 공유 데이터에 접근하지 않는 코드

- 임계영역 진입 코드(entry code)
  - 상호 배제를 위한 처리 수행
    - 현재 스레드만이 임계 영역의 수행 할 수 있도록 처리

- 임계영역 코드(critical code)
  - 공유 데이터를 접근하는 코드

- 임계영역 진출 코드(exit code)
  - 상호 배제를 위한 처리 수행
    - 다른 스레드가 임계 영역에 진입할 수 있도록 처리




<상호 배제 구현>
- 목표 : 오직 하나의 스레드만 진입을 허용
  - 'entry/exit' 코드를 어떻게 구현하나

- 구현 방법
  - 소프트웨어적 방법

  - 하드웨어적 방법
    - 방법1 : 인터럽트 서비스 금지
      - 인터럽트가 발생해도 CPU가 인터럽트를 무시하도록 하는 CPU 명령 이용

    - 방법2 : 원자 기계 명령(atomic instruction)사용
      - 오늘날 상호 배제 구현에 가장 많이 사용하는 방법
      - 임계영역 진입 시, 임계영역을 잠그고 들어가는 원자 명령 사용




<상호배제 구현1 : 인터럽트 서비스 금지>
- 임계구역에 대한 상호배제를 만드는 간단한 방법
  cli ; entry 코드. 인터럽트 서비스 금지 명령 cli(clear interrupt flag)
  ...
  임계구역 코드
  ...
  sti ; exit 코드. 인터럽트 서비스 허용 명령 sti(set interrupt flag)

  - 인터럽트가 발생해도 CPU는 무시하고 ISR 실행 안한다
    - 임계 영역을 실행하던 스레드는 중단되지 않는다





<인터럽트 서비스 금지 방법의 문제점>
- 모든 인터럽트가 무시되는 문제 발생
  - 중요 ISR의 실행이 지연될 수 있다

- 멀티 코어 CPU나 다중 CPU를 가진 시스템에서는 활용 불가
  - 한 CPU의 인터럽트 금지가 다른 코어/CPU의 인터럽트 서비스까지 금지할 수는 없다
    - 다른 코어/CPU에서 실행되는 스레드가 임계 영역에 진입 가능





<상호배제 구현2 : 락을 이용한 상호배제 시도>
- locking/unlocking 방식으로 임계구역의 entry/exit 코드 작성하면 상호배제가 가능할까?
  - lock 변수 : 1이면 잠금 상태
  - lock 변수 : 0이면 열린 상태

- ex)
entry 코드 //locking, lock 변수의 초기 값은 0
I1:
mov ax, [lock] //lock 변수 읽기
mov [lock], 1 //lock 변수에 1 저장
cmp, ax, 0 //이전 lock 변수가 0인지 비교
jne I1 //같지 않으면 I1으로 점프, 같으면 임계구역 진입
----------------------------------
  임계구역
----------------------------------
exit 코드 //unlocking
  mov [lock], 0 //lock 변수에 0 저장





<lock 변수 이용한 상호배제의 실패 원인>
- entry 코드의 한계
  - lock 변수의 값을 읽는 명령과 저장하는 명령 사이에 '컨텍스트 스위칭'이 될 때에만 문제가 발생
  - 그 외의 경우에는 문제 없음
  
- ex)
  I1:
mov ax, [lock] //lock값을 읽고
---------------------- 컨텍스트 스위칭
mov[lock], 1 //lock에 1기록
cmp ax, 0
jne I1






<lock 변수 이용한 상호배제의 해결책>
- 원자 명령(atomic instruction) 도입
  - lock 변수를 읽어 들이는 명령과 값을 저장하는 명령을 '하나의 CPU 명령'으로 처리
    - atomic operation

  - 원자 명령 : TLS(Test and Set Lock) 
    - 오늘날 대부분의 CPU에서 제공

- ex)
  mov ax, [lock]
  mov [lock], 1        --->     TSL ax, [lock] //원자 명령






<lock 변수와 TSL을 이용한 상호배제>
(a) 상호배제 실패
entry 코드 
I1:
mov ax, [lock]
mov [lock], 1
cmp ax, 0
jne I1
--------------------------임계구역
exit 코드
mov[lock], 0


(b) 원자명령 TSL를 사용하여 상호배제 성공
entry 코드
I1:
TSL ax, [lock]
cmp ax, 0
jne I1
--------------------------임계구역
exit 코드
mov[lock]. 0






<멀티스레드 동기화 기법>
- 상호 배제 기반 위에서 여러 스레드들이 자원을 원활히 공유할 수 있도록 하는 기법
  - 동기화 프리미티브(synchronization primitives)로 부름
  - '임계 영역' 진입 시 상호배제를 위해 원자 명령 사용






<대표적인 멀티 스레드 동기화 기법>
- locks 방식 : 뮤텍스(mutex), 스핀락(spinlock)
  - 상호 배제가 되도록 만들어진 'lock 변수' 활용
    - lock을 소유한 스레드만이 임계 영역 진입
    - lock을 소유하지 않은 스레드는 대기

- wait-signal 방식 : 세마포어(semaphore)
  - 'n개의 자원'을 사용하려는 m개의 스레드의 원활한 관리 제공
    - 자원을 소유하지 못한 스레드는 대기(wait)
    - 자원의 사용을 마친 스레드는 알림(signal)





<뮤텍스>
- 잠김/열림 중 한 상태를 갖는 lock 변수 이용
  - 한 스레드만 임계 영역에 진입시키고
  - 나머지 스레드는 '큐에 대기'
    - sleep-waiting lock, blocking lock 기법이라고도 한단





<뮤텍스의 구성 요소>
- lock 변수
  - true : lock을 잠근다. lock을 소유한다
  - false : lock을 연다. lock을 해제한다

- 대기 큐
  - lock이 열리기를 기다리는 스레드 큐

- 연산
  - lock 연산(임계 영역의 entry 코드)
  - unlock 연산(임계 영역의 exit 코드)






<뮤텍스의 연산>
- lock 연산(임계 영역의 entry 코드)
  - 잠김 상태(lock = true)이면, 현재 스레드를 블록 상태로 만들고 '대기 큐'에 진입
  - lock이 열린 상태이면, lock을 잠그고 임계 영역 진입

- unlock 연산(임계 영역의 exit 코드)
  - lock을 열린 상태(lock = false)로 변경
  - 대기 큐에서 기다리는 스레드 하나 깨움






<뮤텍스를 이용한 동기화의 특징>
- 임계 영역에서의 실행 시간이 짧은 경우 비효율적
  - 특정 스레드가 임계 영역 접근시 lock이 잠겨있으면 대기 큐에서 대기시키고 컨텍스트 스위칭
  - lock이 풀리면 깨어난 후 컨텍스트 스위칭하여 CPU를 할당 받아 실행 재개

  - 두 번의 컨텍스트 스위칭 시간 발생
    - lock이 잠겨 있는 시간보다
    - 스레드를 잠자게/깨어나게 하는데 걸리는 시간이 더 작아야





<POSIX 라이브러리에서 제공하는 뮤텍스>
- 뮤텍스 락 변수 : pthread_mutex_t lock;

- 뮤텍스 조작 함수
  - pthread_mutex_init() : 뮤텍스락 변수 초기화
  - pthread_mutex_lock() : 뮤텍스락 잠그기
  - pthread_mutex_unlock() : 뮤텍스락 풀기
  - pthread_mutex_destory() : 뮤텍스락 변수 사용
 





<스핀락(spinlock)>
- 기본적으로 뮤텍스와 같으나 대기하는 스레드가 블록되는 대신 'busy-waiting'한다는 점만 다르다
  - 대기 큐를 갖지 않는다
   - 스레드가 큐에서 블록되지 않고 lock이 열릴 때까지 계속 lock 변수값 '검사'
    - CPU를 계속 소모하므로 CPU는 다른 스레드 실행 불가

  - lock을 소유한 스레드만이 자원 배타적 사용
    - 공유 자원 하나마다 하나의 스핀락 사용

  - 공격적은 뮤텍스(aggressive mutex),
    busy-waiting lock 기법





<스핀락의 구성 요소>
- lock 변수
  - true : lock을 잠근다. lock을 소유한다
  - false : lock을 연다. lock을 해제한다

- 연산
  - lock 연산
    - 임계 영역에 들어갈 때 실행되는 entry 코드
    - lock이 잠김 상태면, lock이 풀릴 때까지 무한 루프 돌면서 lock 연산 시도
    - lock이 열린 상태면, lock을 잠그고 바꾸고 임계 영역 실행

  - unlock 연산
    - 임계 영역을 나올 때 실행하는 exit 코드
    - lock을 열린 상태로 변경





<스핀락을 이용한 동기화의 특징>
- 뮤텍스의 non-blocking 모델
- 단일 CPU/코어를 가진 OS에서 비효율적
  - 다소 의미 없는 CPU 시간 낭비
    - 스핀락을 검사하는 스레드는 타임 슬라이스 소비
    - 락을 소유한 스레드가 실행되어야 락이 풀릴 수 있다

  - 임계 영역의 실행 시간이 짧은 경우 효과적






<POSIX 라이브러리에서 제공하는 스핀락>
- 스핀락 변수 : pthread_spinlock_t lock;

- 스핀락 조작 함수
  - pthread_spinlock_init() : 스핀락 변수 초기화
  - pthread_spinlock_lock() : 스핀락 잠그기
  - pthread_spinlock_unlock() : 스핀락 풀기
  - pthread_spinlock_destory() : 스핀락 변수 사용






<하이브리드 뮤텍스(hybrid mutex)>
- 뮤텍스와 스핀락을 혼합한 동기화 방식
  - 오늘날 커널에서 많이 사용됨
  - 처음에는 스핀락처럼 동작하다 일정 시간이 지날 때까지 락을 획득하지 못하면 뮤텍스처럼 블럭된다
  - 적응형 뮤텍스(adaptive mutex), 적응형 스핀락(adaptive spinlock)






<뮤텍스 vs. 스핀락>
- 뮤텍스
  - 락이 잠기는 시간이 긴 경우
  - 단일 CPU를 가진 시스템
  - 사용자 모드, 사용자 응용 프로그램에서 주로 사용

- 스핀락
  - 락이 잠기는 시간이 짧은 경우
  - 멀티 코어/CPU 시스템
  - 커널 모드, 커널 코드나 ISR(Interrupt Service Routine)에서 주로 사용






<세마포어(semaphore)>
- 멀티 스레드 간의 자원 관리 기법
  - 'n'개의 공유 자원을 다수의 스레드가 공유해 사용하도록 돕는 자원 관리 기법






<세마포어의 구성 요소>
- 자원 : n개

- 대기 큐 
  - 자원을 할당받지 못한 스레드들을 위한 대기 큐

- counter 변수
  - 사용 가능한 자원의 개수를 나타냄
  - 정수형 전역 변수 : 자원의 개수(n)로 초기화

- P/V 연산 : atomic operation
  - P(wait) 연산 : 자원의 사용 허가를 받기 위해 자원 요청 시 실행
  - V(signal) 연산 : 자원 사용이 끝났음을 알리기 위해 자원 발환 시 실행





<세마포어의 종류>
- 자원을 할당 받지 못한 경우의 처리에 따라 구분
  - sleep-wait 세마포어
    - P 연산 : 대기 큐에서 잠자기
    - ex) P 연산
{ //wait
counter--; //자원 요청
...현재 스레드를 대기 큐에 삽입...//sleep-wait
}
...자원 획득...
}

    - V 연산 : 사용 가능한 자원이 있으면 잠자는 스레드 깨우기
    - ex) V 연산
{ //signal
counter++; //자원 반환
if counter <=0 { //기다리는 스레드 있는 경우
  ...대기 큐에서 한 스레드 깨움...
}
}

  - busy-wait 세마포어 //blocking 하지 않는다
    - P 연산 : 사용 가능한 자원이 생길 때까지 무한 반복 검사
    - ex) P 연산
{ //wait
while counter <= 0; //busy-wait
counter--;
}

    - V 연산 : counter++; 
    - ex) V 연산
{ //signal
counter++;
}






<POSIX 라이브러리에서 제공하는 세마포어>
- 세마포어 구조체 : sem_t s

- 세마포어 조작 함수
  - sem_init() : 세마포어 변수 초기화
  - sem_destory : 세마포어 초기화
  - sem_wait() : P 연산을 수행하는 함수(blocking)
  - sem_trywait() : P연산을 수행하는 함수(non-blocking)
  - sem_post() : V 연산을 수행하는 함수
  - sem_getvalue() : 세마포어의 현재 counter 값을 반환






<카운팅 세마포어와 이진 세마포어>
- 카운팅 세마포어(counting semaphore)
  - 자원의 인스턴스가 여러 개인 경우

- 이진 세마포어(binary semaphore)
  - 자원이 1개 있는 경우의 멀티 스레드 간의 자원 관리
  - 1개 자원에 대해 배타적 접근 위해
    - 뮤텍스와 유사





<이진 세마포어의 구성 요소>
- 세마포어 변수 S
  - 0과 1중 하나를 가지는 전역 변수
    - S는 1로 초기화

- 대기 큐
  - 사용 가능한 자원이 생길 때까지 스레드들이 대기
  - 스레드 스케줄링 알고리즘 필요

- 연산
  - wait 연산 : 자원의 사용 허가를 얻는 과정 처리
  - signal 연산 : 자원 사용이 끝났음을 알리는 과정 처리






<뮤텍스 vs. 세마포어>
- 뮤텍스
  - 하나의 자원에 대한 상호 배제
    - 자원 : 공유 변수
    - 하나의 자원에 대한 이진 세마포어
    - 예 : 한 칸짜리 화장실에 하나의 키가 있는 경우

- 세마포어
  - 각 자원에 대한 여러 인스턴스 관리
    - 자원 : 버퍼, 로그인 개수
    - 자원 제어와 시그널링에 사용
    - 예 : 독립된 키를 갖는 4칸짜리 화장실의 경우






<동기화 이슈 : 우선순위 역전(priority inversion)>
- 높은 우선순위의 스레드가 낮은 우선순위의 스레드보다 늦게 스케줄링 되는 현상
  - 실시간 시스템이나 우선순위를 기반으로 하는 시스템에서 스레드의 동기화로 인해 발생
  - 실시간 시스템의 근본이 붕괴될 수 있음
  - 중요한 일을 할 가능성이 있는 스레드의 처리가 지연





<우선 순위 역전의 해결책>
- 구현이 쉽지 않고 오버헤드 존재
  - 우선순위 올림(priority ceiling)
    - 스레드가 공유 자원을 소유하게 될 때, 스레드의 우선순위를 미리 정해진 높은 우선순위로 일시적으로 올림
      - 공유 자원을 접근할 어떤 스레드보다도 높게 설정
    - 선점되지 않고 빨리 실행되도록 유도
    - 공유 자원에 대한 사용이 끝나면 우선순위 복원

  - 우선순위 상속(priority inheritance)
    - 낮은 우선순위의 스레드가 공유 자원을 갖고 있는 동안
    - 높은 순위의 스레드가 공유 자원을 요청하면,
    - 공유 자원을 가진 스레드의 우선순위를 요청한 스레드보다 높게 설정하여 계속 실행시킴
    - 공유 자원에 대한 사용이 끝나면 우선순위 복원






<생산자-소비자 문제>
- 정보 생상자 스레드와 정보 소비자 스레드가 공유한 공유 버퍼를 통해 정보를 원활히 주고 받을 수 있도록 동기화
  - 많은 멀티 스레딩 app에서 발생하는 전형적인 동기화 문제
  - 생산자 스레드(producer) -> 공유 버퍼 -> 소비자 스레드(consumer)

- ex) (a) 미디어 플레이어의 구조(1:1 생산자 소비자 관계)
네트워크, 비디오 파일 -> 입력스레드(생산자) -> 비디오 버퍼 -> 재생스레드(소비자) -> 디스플레이

- ex) (b) 스트리밍 서버의 구조(1:N 생산자 소비자 관계)
비디오 파일 -> 입력스레드(생산자) -> 비디오 버퍼 -> 송신스레드1, 송신스레드2, 송신드레드3(소비자) -> 인터넷 -> 미디어 플레이어1, 미디어 플레이어2, 미디어 플레이어3 








<생산자-소비자 문제의 주된 관심사>
- 생상사-소비자들의 공유 버퍼에 대한 상호 배제
  - 뮤텍스나 세마포어를 이용해 해결
- 비어있는 경우 공유 버퍼를 소비자가 읽으려 할 때
- 꽉 찬 공유 버퍼에 생산자가 추가하려 할 때






<꽉 찬 공유 버퍼 문제의 해결 방안>
- 세마포 W(쓰기 가능한 버퍼 개수) 활용 : 버퍼가 꽉 차 있을 때 처리하는 P/V 연산으로 해결
  (1) 공유 버퍼가 찬 상태에서, 생산자가 쓰려고 할 때
    생산자 : 버퍼에 쓰기 전 P 연산 실행
    P 연산 : 버퍼가 꽉 찬 상태(W=0)이면, 생산자가 잠을 자면서 대기하도록 작성

  (2) 버퍼에 데이터가 있는 경우, 소비자가 읽을 때
    소비자 : 버퍼에서 데이터를 읽은 후 V 연산 실행
    V 연산 : 세마포 변수 W를 1증가시키고(W=1), 대기 중인 생산자를 깨우도록 작성
    생산자 : 깨어나면 P연산을 마치고 공유 버퍼에 쓴다. P연산에서 세마포 W를 1감소(W=0)







<생산자-소비자 알고리즘>
- 카운팅 세마포어
  - W : 버퍼에 쓰기 가능한 저장 공간의 수
    - 0이면 꽉 찬 상태

   - R : 버퍼에서 읽기 가능한 저장 공간의 수
    - 0이면 비어있는 상태
  
- 뮤텍스
  - M : 공유 버퍼의 배타적 접근을 위해 모든 생산자-소비자 스레드에 의해 사용







<생산자-소비자 알고리즘>
Consumer{ //소비자 스레드
while(true){
P(R); //세마포R에 P/wait 연산을 수행하여
//버퍼가 비어있으면(읽기 가능한 버퍼 수=0) 대기한다.

배타적 사용을 위해,
뮤텍스(M)을 잠근다. 
공유버퍼에서 데이터를 읽는다. //임계구역 코드
뮤텍스(M)을 연다.

V(W); //세마포 W에 대해 V/signal 연산을 수행하여
//버퍼가 비기를 기다리는 Producer를 깨운다
}
}

Producer{ //생산자 스레드
while(true){
P(W); //세마포 W에 P/wait 연산을 수행하여
//버퍼가 꽉 차 있으면(쓰기 가능한 버퍼 수=0) 대기

배타적 사용을 위해, 
뮤텍스(M)를 잠근다.
공유버퍼에 데이터를 저장한다. //임계구역 코드
뮤텍스(M)을 연다.

V(R); //세마포 R에 대해 V/signal 연산을 수행하여
//버퍼에 데이터가 저장되기를 기다리는 Consumer를 깨운다
}
}






<생산자-소비자 app의 구성(1)>
- 1개의 생산자, 1개의 소비자 스레드로 구성
  - 생산자 스레드 
    - 0~9까지의 정수를 임의 시간 간격으로 공유 버퍼에 씀
 
  - 소비자 스레드
    - 공유버퍼로부터 임의 시간 간격으로 정수를 읽어 출력
  
  - 공유 버퍼
    - 4개의 정수를 저장하는 원형 큐
    - 배열로 작성






<생산자-소비자 app의 구성(2)>
- 2개의 세마포어
  - semWrite
    - 공유 버퍼에 쓰기 가능한 공간의 개수를 나타냄
    - 초기값이 4인 conuter를 가짐

  - semRead
    - 공유 버퍼에 읽기 가능한 공간의 개수를 나타냄
    - 초기값이 0인 counter를 가짐

- 1개의 뮤텍스
  - 공유 버퍼 접근 코드를 임계 영역으로 설정

'운영체제' 카테고리의 다른 글

교착상태(Deadlock)  (1) 2024.04.28
CPU 스케줄링  (0) 2024.04.04
스레드와 멀티태스킹  (0) 2024.03.28