동시성과 관련한 문제는 동시성 제어 메커니즘이 없어서 발생하는 경우, 사용해서 발생하는 경우로 구분된다.
전자는 모든 동시 시스템의 본질적 속성이고, 후자는 잘못된 방식으로 제어 메커니즘을 사용할 때만 발생한다.
--
하나 이상의 작업이 있는 모든 동시 시스템은 다수의 인터리빙이 존재한다. 이는 비결정론적임과 동시에 발생 전 제약(happen-before constraints)을 따른다. 동기화 메커니즘을 도입해 시스템에 대해 불변해야 하는 불변 제약 조건(invariant constraint)을 통제할 필요가 있다.
경쟁 상태란 불변 제약 조건을 만족하지 않는 인터리빙이 존재하는 상태를 의미한다. 이로 인해 공유 변수의 데이터 무결성이 손상될 수 있다.
코드 박스 14-1 [예제 14-1] 하나의 공유 변수로 작업하는 세 개의 동시 작업이 있는 시스템
동시 시스템 {
공유 상태 {
카운터 : 정수 = 0
}
작업 T1 {
A : 정수
1.1. A = 카운터
1.2. A = A + 1
1.3. 카운터 = A
}
작업 T2 {
B : 정수
2.1. B = 카운터
2.2. B = B + 1
2.3. 카운터 = B
}
작업 T3 {
A : 정수
3.1. A = 카운터
3.2. A = A + 1
3.3. 카운터 = A
}
}
이 시나리오에 대한 불변 제약 조건은 카운터 변수가 모든 작업 실행 후 3을 저장해야 한다는 것이다. 그리고 이는 특정 인터리빙에서 위반될 수 있으므로 경쟁 상태에 있다. 이러한 상황을 데이터 경쟁이라고 한다.
코드 박스 14-3 [코드 박스 14-1]의 예제에 대한 불변 제약 조건을 위반하는 인터리빙
동시 시스템 {
공유 상태 {
char* ptr = NULL; // default value of a pointer to heap space should be null
}
작업 P {
1.1. ptr = (char*) malloc(10 * sizeof(char));
1.2. ctrcpy(ptr, "Hello!");
1.3. printf("%s\n", ptr);
}
작업 Q {
2.1. free(ptr);
2.2. ptr = NULL;
}
}
이 시나리오 역시 경쟁 상태에 있으며 동시성 문제가 시스템 충돌을 유발할 수 있다. 일반적으로 복잡한 시스템에서 경쟁 상태를 즉시 식별하기는 어렵기 때문에, 실행 빈도가 낮은 코드 분기에서 기존 경쟁 상태를 찾아내는 경쟁 탐지기(race detector) 등을 사용할 수 있다.
코드 박스 14-4 [예제 14-2] 경쟁 상태를 겪는 아주 단순한 동시 시스템
동시 시스템 {
공유 상태 {
X : 정수 = 0
}
작업 P {
1.1. X = 1
}
작업 Q {
2.1. print X
}
}
이 시나리오에서 불변 제약 조건은 X의 값이 1이어야 한다는 것이다. 이를 만족하기 위해 서로 다른 작업 간의 순서가 보장되어야 한다. 이러한 경우엔 순서를 복구하는 메커니즘을 두어야 한다.
코드 박스 14-5 [예제 14-3] 경쟁 상태가 발생했으나 공유 상태를 전혀 갖지 않는 매우 단순한 시스템
동시 시스템 {
공유 상태 {}
작업 P {
1.1. print 3
}
작업 Q {
2.1. print 1
}
작업 R {
3.1. print 2
}
}
이 시나리오에서 불변 제약 조건을 1, 2, 3 순서로 출력되어야 하는 것이라 정의한다면, 마찬가지로 작업 Q, R, P 순서로 실행되는 것이 보장되어야 한다. 공유 상태가 없음에도 경쟁 상태가 발생할 수 있는 사례이다.
경쟁 상태는 임계 구역(critical) 안의 명령어 집합이 순서대로 실행되지 않을 때만 발생된다. 임계 구역 밖의 명령어 집합은 실행 순서가 상관없다.
코드 박스 14-6 [예제 14-4] 공유된 변수 X에 대한 데이터 경쟁이 발생한 동시 시스템
동시 시스템 {
공유 상태 {
X : 정수 = 2
}
작업 P {
A : 정수
1.1. A = X
1.2. A = A + 1
1.3. X = A
}
작업 Q {
B : 정수
2.1. B = X
2.2. B = B + 3
2.3. X = B
}
}
이 시나리오에서 데이터 무결성을 보장하려면 작업 P, Q에 존재하는 세 개의 명령어가 각각 원자적으로 실행되어야 한다.
일부 인터리빙이 공유 상태의 데이터 무결성에 관한 제약 조건을 위반하면 데이터 경쟁이 발생한다. 그러므로 읽기 전용인 공유 상태는 데이터 경쟁이 발생하지 않는다.
코드 박스 14-7 [예제 14-5] 읽기 전용 공유 상태가 있는 동시 시스템
동시 시스템 {
공유 상태 {
X : 정수 (읽기 전용) = 5
}
작업 P {
A : 정수
1.1. A = X
1.2. A = A + 1
1.3. print A
}
작업 Q {
2.1. print X
}
작업 R {
B : 정수
3.1. B = X + 1
3.2. B = B + 1
3.3. print B
}
}
이 시나리오는 데이터 경쟁이 존재하지 않는다. 하지만 불변 제약 조건을 5, 6, 7을 출력하는 것이라고 가정한다면, 서로 다른 작업 간 엄격한 순서가 필요하므로 경쟁 상태가 발생한다.
경쟁 상태가 발생하는 모든 시나리오에 대해 동기화 메커니즘을 통해 모든 가능한 인터리빙이 주어진 순서를 따르도록 강제해야 한다.
동기화 메커니즘을 잘못 사용한 결과 발생할 수 있는 주요한 네 가지 문제는 다음과 같다.
시스템의 스케줄러는 공정하기 때문에 기아 상태, 교착 상태는 동시 시스템에 기본적으로 존재하지 않는다.
동시 시스템은 각각 고유한 불변 제약 조건이 있으며, 이를 위반하는 인터리빙을 대체하는 새로운 인터리빙을 생성해야 한다. 새로운 인터리빙은 서로 다른 작업의 명령어 간 발생 전 제약(happens-before constraint)을 부과하여 불변 제약 조건을 만족할 수 있도록 한다.
코드 박스 14-8 제어 메커니즘 도입 이전의 [예제 14-6]을 나타내는 동시 시스템
동시 시스템 {
작업 P {
1.1. print 'A'
}
작업 Q {
2.1. print 'B'
}
}
불변 제약 조건을 A, B 순서대로 출력되는 것이라 가정하면, 인터리빙 {2.1, 1.1}은 이를 위반하므로 경쟁 상태에 있다.
코드 박스 14-9 [예제 14-6]에 대해 바쁜 대기를 이용하는 해결 방법
동시 시스템 {
공유 상태 {
완료 : 불리언 = 거짓
}
작업 P {
1.1. print 'A'
1.2. 완료 = 참
}
작업 Q {
2.1. 완료되지 않은 동안 아무것도 하지 않음
2.2. print 'B'
}
}
이 시나리오의 모든 인터리빙은 명령어 1.2 뒤에 2.1이 실행된다. 작업 Q는 자신의 타임 슬라이스 동안 폴링(polling) 외에 아무것도 수행하지 않으며 자원을 낭비한다. 이를 바쁜 대기(busy-wait)라고 한다.
잠자기/알림(sleep/notify) 또는 대기/알림(wait/notify) 메커니즘은 이전 시나리오에서 작업 Q가 타임 슬라이스 동안 바쁜 대기를 수행하는 대신, 플래그 값을 검사하고 유효하지 않다면 즉시 잠자기에 들어가는 방식이다. 작업 P는 플래그를 참으로 수정한 후 작업 Q를 깨워야 한다. 이 방식이 관습적인(de facto) 구현이다.
코드 박스 14-10 [예제 14-6]에 대해 잠자기/알림을 사용하는 해결 방법
동시 시스템 {
작업 P {
1.1. print 'A'
1.2. notify to 작업 Q
}
작업 Q {
2.1. sleep
2.2. print 'B'
}
}
잠자기 모드로 들어간 작업은 작업 스케줄러가 타임 슬라이스를 할당하지 않는다. 그러므로 바쁜 대기 시 낭비되었던 CPU 자원을 다른 작업이 사용할 수 있다.
잠자는 작업을 깨워주는 메커니즘은 일반적으로 알림 또는 신호 전달(signaling)으로 수행된다.
코드 박스 14-11 [코드 박스 4-10]의 동시 시스템을 반교착 상태로 만드는 인터리빙
1.1. print 'A'
1.2. notify to 작업 Q
2.1. sleep
2.2. print 'B'
하지만 위와 같은 인터리빙이 동기화 이후 문제인 교착 상태를 유발한다. 이 문제를 해결하기 위해 불리언 플래그를 활용할 수 있다.
코드 박스 14-12 [예제 14-6]에 대해 잠들기/알림 접근법에 따라 개선된 해결 방법
동시 시스템 {
공유 상태 {
완료 : 불리언 = 거짓
}
작업 P {
1.1. print 'A'
1.2. 완료 = 참
1.3. notify to 작업 Q
}
작업 Q {
2.1. 완료가 아닌 동안 {
2.2. 완료가 거짓이라면 잠자기 모드에 돌입 (원자적)
2.3. }
2.4. print 'B'
}
}
잠든 작업 Q가 작업 P 뿐만 아니라 시스템에서 발생하는 다양한 이벤트에 의해 알림을 받아 깨어날 수 있으므로 루프가 필요하다. 이 해결 방법은 제한적인 가정 하에 잘 동작하지만, 멀티프로세서 유닛에서는 반교착 상태가 발생할 여지가 있다.
세마포어(semaphore)란 공유 자원에 대한 접근을 동기화하기 위해 사용하는 변수 또는 객체이다. 세마포어를 사용해 임계 구역을 보호할 수 있다.
코드 박스 14-13 [예제 14-7] 세마포어를 이용해 두 작업을 동기화하기
동시 시스템 {
공유 상태 {
S : 한 번에 하나의 작업만 허용하는 세마포어
카운터 : 정수 = 0
}
작업 P {
A : 지역 정수
1.1. 임계 구역 진입 : EnterCriticalSection(S)
1.2. A = 카운터
1.3. A = A + 1
1.4. 카운터 = A
1.5. 임계 구역 떠나기 : LeaveCriticalSection(S)
}
작업 Q {
B : 지역 정수
2.1. 임계 구역 진입 : EnterCriticalSection(S)
2.2. B = 카운터
2.3. B = B + 2
2.4. 카운터 = B
2.5. 임계 구역 떠나기 : LeaveCriticalSection(S)
}
}
한 번에 하나의 작업만 임계 구역 진입을 허용하는 세마포어는 이진 세마포어(binary semaphore) 또는 뮤텍스(mutex, mutual exclusion)라고 한다.
세마포어와 뮤텍스는 잠글 수 있는(lockable) 객체라고 한다. 세마포어를 기다리고 임계 구역에 입장하는 행위는 잠금(lock), 세마포어를 떠나거나 업데이트하는 행위는 잠금 해제(unlock)와 같다. 이러한 메커니즘은 스핀락(spin lock)으로 구현될 수도 있고, 잠자기/알림(sleep/notify)**으로 구현될 수도 있다.
코드 박스 14-14 세마포어로 작동하는 잠금 및 해제 작업 사용하기
동시 시스템 {
공유 상태 {
S : 한 번에 하나의 작업만 허용하는 세마포어
카운터 : 정수 = 0
}
작업 P {
A : 지역 변수
1.1. 잠금 : Lock(S)
1.2. A = 카운터
1.3. A = A + 1
1.4. 카운터 = A
1.5. 잠금 해제 : Unlock(S)
}
작업 Q {
B : 지역 변수
2.1. 잠금 : Lock(S)
2.2. B = 카운터
2.3. B = B + 2
2.4. 카운터 = B
2.5. 잠금 해제 : Unlock(S)
}
}
여러 작업이 임계 구역에 들어가려 할 때 세마포어에 대한 잠금을 얻기 위해 경합(contention)이 발생한다. 경합 상태(contention state)에서 작업이 대기하는 시간을 경합 시간이라고 한다.
싱글코어 시스템에서 작업들은 CPU 코어의 지역 캐시(local cache)에 저장된 메인 메모리 주소에 접근해 최신 값을 읽을 수 있다. 하지만 멀티코어 시스템에서는 지역 캐시에 저장된 값이 메인 메모리의 최신 값을 읽고 있다고 보장할 수 없다. 이러한 문제는 CPU 코어 간 메모리 일관성 프로토콜(memory coherence protocol)을 도입해 해결한다. 메모리 일관성 프로토콜을 준수하면 모든 작업에 메모리 가시성(memory visibility)이 도입된다.
코드 박스 14-15 잠자기/알림 기술 기반의 [예제 14-6]에 대한 해결 방법
동시 시스템 {
공유 상태 {
완료 : 불리언 = 거짓
}
작업 P {
1.1. print 'A'
1.2. 완료 = 참
1.3. notify to 작업 Q
}
작업 Q {
2.1. 완료가 아닌 동안 {
2.2. 완료가 거짓이라면(원자적) 잠자기 모드에 돌입
2.3. }
2.4. print 'B'
}
}
작업 P, Q가 서로 다른 CPU 코어에서 실행 중이라고 가정하자. 이때 각 CPU 코어에 공유 변수 완료에 대한 고유한 진입점(entry point)이 존재한다. 명령 1.2를 실행할 때 작업 P는 지역 캐시의 값을 업데이트하지만 그것이 메인 메모리나 작업 Q가 실행되는 코어의 지역 캐시로 전파되는 것은 아니다. 그러므로 작업 Q는 명령 2.1의 루프를 무한히 실행하여 반교착 상태가 발생한다.
이 문제 해결을 위해 메모리 장벽을 사용해야 한다. 메모리 장벽은 실행(전달) 시 장벽과 같은 역할을 하는 명령어로, 하나의 지역 캐시에 있는 모든 값이 메모리 및 다른 지역 캐시로 전파되게 하여 동기화한다.
코드 박스 14-16 메모리 장벽으로 [예제 14-6]에 대한 해결 방법 향상하기
동시 시스템 {
공유 상태 {
완료 : 불리언 = 거짓
}
작업 P {
1.1. print 'A'
1.2. 완료 = 참
1.3. 메모리 장벽
1.4. notify to 작업 Q
}
작업 Q {
2.1. 수행 {
2.2. 메모리 장벽
2.3. 완료가 거짓이라면(원자적) 잠자기 모드에 돌입
2.4. }
2.5. print 'B'
}
}
코드 박스 14-17 뮤텍스로 [예제 14-6]에 대한 해결 방법 향상하기
동시 시스템 {
공유 상태 {
완료 : 불리언 = 거짓
M : 뮤텍스
}
작업 P {
1.1. print 'A'
1.2. 잠금 : Lock(M)
1.3. 완료 = 참
1.4. 잠금 해제 : Unlock(M)
1.5. notify to 작업 Q
}
작업 Q {
2.1. 잠금 : Lock(M)
2.2. 완료가 아닌 동안 {
2.3. 잠자기 모드로 들어가고 잠금 해제(원자적) : Unlock(M)
2.4. 잠금 : Lock(M)
2.5. }
2.6. 잠금 해제 : Unlock(M)
2.7. print 'B'
}
}
뮤텍스는 다음 세 가지 경우에 자동으로 해제된다.
Unlock 명령어를 사용하면 뮤텍스가 해제된다.작업이 뮤텍스를 여러 번 잠그려고 하면 대개 교착 상태가 된다. 재귀적인 뮤텍스(recursive mutex)만 작업 하나에 여러 번 잠글 수 있다. 재귀적 뮤텍스를 사용할 때는 잠금 횟수와 해제 횟수를 일치시켜야 한다.
다음은 스핀락 알고리즘이 적용된 뮤텍스를 사용하는 해결 방법이다. 뮤텍스가 메모리 장벽 역할을 하기에 메모리 가시성 문제가 없다.
코드 박스 14-18 [예제 14-6]에 대해 뮤텍스와 스핀락 알고리즘을 사용하는 해결 방법
동시 시스템 {
공유 상태 {
완료 : 불리언 = 거짓
M : 뮤텍스
}
작업 P {
1.1. print 'A'
1.2. 스핀락 : SpinLock(M)
1.3. 완료 = 참
1.4. 스핀락 해제 : SpinUnlock(M)
}
작업 Q {
2.1. 스핀락 : SpinLock(M)
2.2. 완료가 아닌 동안 {
2.3. 스핀락 해제 : SpinUnlock(M)
2.4. 스핀락 : SpinLock(M)
2.5. }
2.6. 스핀락 해제 : SpinUnlock(M)
2.7. print 'B'
}
}
이 의사코드는 동시성을 지원하는 모든 프로그래밍 언어에 이식될 수 있으며, POSIX 스레딩 API를 사용해 유효한 C 코드로 작성할 수 있다. 스핀락 뮤텍스는 잠글 때마다 바쁜 대기 루프에 진입한다.
이벤트 발생 비율에 비교해 작업을 잠자기 모드로 두는 것이 매우 비싼 고성능 시스템에서는 스핀락이 일반적인 방식이다. 스핀락을 사용할 때 작업은 가능한 한 빠르게 뮤텍스를 해제할 수 있는 방식으로 작성해야 하고, 임계 구역이 매우 작아야 한다.
조건 변수는 작업에 대한 잠자기와 알림 기능이 있는 변수(또는 객체)이다. 이 기능은 각각 대기(wait), 신호(signal)로 불리기도 한다. 조건 변수만을 사용한 해결법에는 상호 배제(mutual exclusion) 속성이 없기 때문에 반드시 뮤텍스와 함께 사용해야 한다.
코드 박스 14-19 [예제 14-6]에 대해 조건 변수를 사용한 해결 방법
동시 시스템 {
공유 상태 {
완료 : 불리언 = 거짓
CV : 조건 변수
M : 뮤텍스
}
작업 P {
1.1. print 'A'
1.2. 잠금 : Lock(M)
1.3. 완료 = 참
1.4. 알리기 : Notify(CV)
1.5. 잠금 해제 : Unlock(M)
}
작업 Q {
2.1. 잠금 : Lock(M)
2.2. 완료가 아닌 동안 {
2.3. 잠자기 : Sleep(M, CV)
2.4. }
2.5. 잠금 해제 : Unlock(M)
2.6. print 'B'
}
}
뮤텍스 객체와 조건 변수를 모니터 객체(monitor object)라고 한다.
POSIX 호환 운영체제의 동시성은 일반적으로 멀티프로세싱(multiprocessing) 또는 멀티스레딩(multithreading) 두 가지 방식으로 제공된다.
모든 커널은 작업 스케줄러 유닛(task scheduler unit)을 통해 실행 중인 여러 프로세스 및 스레드 사이에서 CPU 코어를 공유한다. 스케줄링 알고리즘을 사용해 작업을 공정하게 실행하며, 이는 크게 다음의 두 가지로 분류된다.
현대 커널은 보통 선점 스케줄링 알고리즘을 사용하지만, 실시간 프로세싱(real-time processing)과 같은 특정 응용 프로그램에 대해 비선점 스케줄링을 사용하는 커널은 있다. macOS, Windows 운영체제의 초기 버전은 비선점 스케줄링을 사용했다.
멀티프로세싱은 다중 작업 환경에서 병렬 작업을 위해 사용자 프로세스를 사용한다. 그리고 멀티스레딩은 단일 프로세스 내의 작업을 병렬적인 실행 흐름으로 분할하기 위해 사용자 스레드를 사용한다.
멀티프로세싱이란 동시 작업을 수행하는 프로세스를 사용한다는 의미이다. 웹 서버의 공용 게이트웨이 인터페이스(CGI: Common Gateway Interface)가 대표적인 예로, 이 기술을 사용하면 각 HTTP 요청에 대해 새로운 인터프리터 프로세스(interpreter process)를 생성하여 동시 요청을 처리한다.
또한, 다중 프로세스 간 정보 공유가 필요한 기술의 예로 하둡(Hadoop) 인프라가 있다. 이는 여러 노드로 구성되며, 각 노드는 클러스터를 무중단 실행하는 여러 프로세스로 구성된다. 무중단 실행을 위해 프로세스 간 메시지 전달이 필요하다.
메시지 공유가 필요하지 않은 경우 멀티프로세싱과 멀티스레딩의 기술적 복잡도는 비슷하지만, 멀티프로세싱 방식은 기본적으로 메모리 공간을 직접 공유하지 않기 때문에 메시지 공유가 필요할 경우 기술적 복잡도가 올라간다. 이때는 다음과 같은 상태 공유 기술이 사용될 수 있다.
멀티스레딩은 동시 환경에서 병렬 작업을 수행하기 위해 사용자 스레드를 이용하는 것이다. 스레드는 프로세스 내에만 존재할 수 있으며, 각 프로세스는 기본적으로 메인 스레드(main thread)를 가진다. 단일 스레드로 모든 작업을 수행하는 프로그램은 단일 스레드(single-threaded) 프로그램이라고 한다. 프로세스 내의 모든 스레드는 같은 메모리 영역에 접근할 수 있으므로 데이터 공유를 위한 기술적 복잡도가 낮다.
각 스레드는 고유한 스택 영역을 가지며, 이 공간에 대한 포인터를 다른 스레드에 전달해 쉽게 공유할 수 있다. 또한 프로세스 소유의 힙 영역이나 데이터 영역을 통해서도 공유 상태 정의가 가능하다.
멀티스레딩 프로그래밍을 위해 POSIX 호환 운영체제는 POSIX 스레딩 API(pthread)를 지원하고, Windows는 Win32 네이티브 라이브러리에 속하는 고유한 API를 지원한다.