공유 변수는 편리하지만, 동기화 오류(synchronization errors)라는 고약한 문제를 일으킬 가능성을 도입합니다.

Figure 12.16의 badcnt.c 프로그램을 살펴봅시다. 이 프로그램은 두 개의 스레드를 생성하며, 각 스레드는 cnt라는 전역 공유 카운터 변수를 증가시킵니다.
각 스레드가 카운터를 niters 번씩 증가시키므로, 우리는 최종값이 가 될 것으로 기대합니다. 이는 매우 간단하고 명확해 보입니다.
하지만 리눅스 시스템에서 badcnt.c를 실행하면, 틀린 답이 나올 뿐만 아니라 실행할 때마다 다른 답이 나옵니다!
linux> ./badcnt 1000000
BOOM! cnt=1445085
linux> ./badcnt 1000000
BOOM! cnt=1915220
linux> ./badcnt 1000000
BOOM! cnt=1404746
무엇이 잘못된 것일까요? 이 문제를 명확히 이해하려면, Figure 12.17에 나온 카운터 루프(lines 40–41)의 어셈블리 코드를 살펴봐야 합니다.

핵심은 C 코드 한 줄인 cnt++가 실제로는 단일 명령어가 아니라는 것입니다. 스레드 의 루프 코드를 5개 부분으로 나눌 수 있습니다.
cnt를 스레드 의 레지스터 %rdx_i로 불러오는 명령어.%rdx_i의 값을 수정(증가)하는 명령어.%rdx_i의 값을 다시 공유 변수 cnt에 저장하는 명령어.여기서 와 는 지역 스택 변수만 다루지만, , , 는 공유 카운터 변수 cnt의 내용을 조작합니다.
두 스레드가 동시에 실행될 때, 기계어 명령어들은 어떤 순서로든 차례대로(interleaving, 뒤섞여서) 완료됩니다. 불행히도, 이 순서 중 일부는 올바른 결과를 생성하지만, 다른 순서들은 그렇지 않습니다.
여기서 핵심은 이것입니다: 일반적으로 프로그래머는 운영체제가 스레드에 대해 올바른 순서를 선택할지 예측할 방법이 없습니다.

L_1 (0 로드), U_1 (1로 증가), S_1 (1 저장)을 순서대로 실행합니다.cnt의 메모리 값은 1이 됩니다.)L_2 (1 로드), U_2 (2로 증가), S_2 (2 저장)를 순서대로 실행합니다.cnt의 메모리 값은 2가 됩니다.)cnt = 2 (정상)L_1 (레지스터 %rdx_1에 0을 로드)U_1 (레지스터 %rdx_1을 1로 증가)L_2 (메모리의 cnt는 아직 0이므로, 레지스터 %rdx_2에 0을 로드)S_1 (자신의 레지스터 값 1을 cnt에 저장. cnt = 1)U_2 (자신의 레지스터 %rdx_2를 1로 증가)S_2 (자신의 레지스터 값 1을 cnt에 저장. cnt = 1)cnt = 1 (오류!)이 문제는 스레드 2가 cnt를 로드(Step 5)할 때, 스레드 1이 cnt를 로드(Step 2)한 후이면서, 스레드 1이 수정된 값을 저장(Step 6)하기 전에 발생했습니다.
결국, 두 스레드 모두 업데이트된 카운터 값으로 1을 저장하게 됩니다. (즉, 스레드 1의 작업이 무시됩니다.)
(Figure 12.19는 badcnt.c의 두 스레드를 2차원 그래프로 보여줍니다. 축은 스레드 1, 축은 스레드 2입니다.)

진행 그래프는 명령어 실행을 한 상태에서 다른 상태로의 전이(transition)로 모델링합니다.
프로그램의 실행 히스토리는 이 상태 공간을 통과하는 하나의 경로(trajectory)로 모델링됩니다. (Figure 12.20은 특정 명령어 순서에 해당하는 경로를 보여줍니다.)

스레드 에서, 공유 변수 cnt의 내용을 조작하는 명령어들(, , )은 임계 구역(critical section)을 구성합니다.
진행 그래프 상에서, 두 임계 구역이 교차(intersection)하는 영역은 위험 영역(unsafe region)이라고 알려진 상태 공간 영역을 정의합니다.
cnt 변수에 대한 위험 영역을 보여줍니다.안전한 경로만이 공유 카운터를 올바르게 업데이트합니다.
공유 데이터를 사용하는 동시성 프로그램의 올바른 실행을 보장하려면, 스레드들이 항상 안전한 경로를 갖도록 스레드들을 어떻게든 동기화(synchronize)해야 합니다.
(이를 위한 고전적인 접근 방식이 바로 다음에 소개할 '세마포'입니다.)

동시성 프로그래밍의 선구자인 에츠허르 다익스트라(Edsger Dijkstra)는 세마포어(semaphore)라는 특별한 타입의 변수에 기반한, 서로 다른 실행 스레드를 동기화하는 고전적인 해법을 제안했습니다.
세마포어 는 음이 아닌 정수 값을 갖는 전역 변수이며, 오직 다음과 같은 두 가지 특별한 연산, P와 V에 의해서만 조작될 수 있습니다.
연산에서의 "검사(test)"와 "감소(decrement)"는 불가분(indivisibly, 원자적으로) 일어납니다. 즉, 일단 세마포 가 0이 아닌 값이 되면, 의 감소는 어떠한 중단(interruption) 없이 발생합니다. 연산에서의 "증가" 역시 (값을 불러오고, 증가시키고, 저장하는 과정이) 중단 없이 불가분하게 일어납니다.
의 정의는 대기 중인 스레드가 어떤 순서로 재시작될지 정의하지 않는다는 점에 유의해야 합니다. 유일한 요구 사항은 가 정확히 하나의 대기 중인 스레드를 재시작해야 한다는 것입니다. 따라서 여러 스레드가 세마포에서 대기 중일 때, 연산의 결과로 어떤 스레드가 재시작될지 예측할 수 없습니다.
와 의 정의는, 올바르게 초기화된 세마포 값이 실행 중에 절대 음수(-)가 될 수 없음을 보장합니다. 이 속성은 세마포 불변성(semaphore invariant)이라고 알려져 있으며, 다음 섹션에서 보게 될 것처럼 동시성 프로그램의 경로(trajectories)를 제어하는 강력한 도구를 제공합니다.
Posix 표준은 세마포를 조작하기 위한 다양한 함수를 정의합니다.
#include <semaphore.h>
int sem_init(sem_t *sem, 0, unsigned int value);
int sem_wait(sem_t *s); /* P(s) */
int sem_post(sem_t *s); /* V(s) */
// 성공 시 0, 오류 시 -1 반환
sem_init 함수는 세마포 sem을 value 값으로 초기화합니다. 각 세마포는 사용되기 전에 반드시 초기화되어야 합니다. (우리의 목적상 가운데 인자는 항상 0입니다.)
프로그램은 sem_wait 함수와 sem_post 함수를 호출하여 각각 P 연산과 V 연산을 수행합니다. (CSAPP에서는 간결함을 위해, 이 함수들을 감싼 다음과 같은 래퍼(wrapper) 함수를 사용할 것입니다.)
#include "csapp.h"
void P(sem_t *s); /* sem_wait의 래퍼 함수 */
void V(sem_t *s); /* sem_post의 래퍼 함수 */
// (리턴값 없음)
세마포는 공유 변수에 대한 상호 배타적인 접근(mutually exclusive access)을 보장하는 편리한 방법을 제공합니다. 기본 아이디어는 각 공유 변수(또는 연관된 변수 집합)에 대해 초기값이 1인 세마포 를 하나씩 연관시키고, 해당 임계 구역(critical section)을 P(s)와 V(s) 연산으로 감싸는 것입니다.
P 연산을 수행하는 것.V 연산을 수행하는 것.
Figure 12.22의 진행 그래프는 이진 세마포를 사용하여 카운터 프로그램을 올바르게 동기화하는 방법을 보여줍니다.
각 상태는 그 상태에서의 세마포 의 값을 함께 표시합니다.
여기서 핵심 아이디어는, P와 V 연산의 조합이 이 되는 상태의 집합, 즉 "금지된 영역(forbidden region)"을 생성한다는 것입니다.
"세마포 불변성"(세마포 값은 절대 음수가 될 수 없음) 때문에, 실행 가능한 어떠한 경로(trajectory)도 이 "금지된 영역"에 포함된 상태를 가질 수 없습니다.
그리고 이 "금지된 영역"이 (경쟁 상태가 발생하는) "위험 영역(unsafe region)"을 완전히 둘러싸고 있습니다.
결과적으로, 실행 가능한 모든 경로는 "금지된 영역"을 피해서 가야 하므로, "위험 영역" 또한 자동으로 피하게 됩니다. 따라서 모든 실행 경로는 "안전한 경로(safe trajectory)"가 되며, 런타임 시 명령어 순서가 어떻게 되든 관계없이 프로그램은 카운터를 올바르게 증가시킵니다.
운영 관점에서, P와 V 연산이 만든 "금지된 영역"은 여러 스레드가 동시에 "위험 영역" 안의 임계 구역 명령어를 실행하는 것을 불가능하게 만듭니다. 즉, 세마포 연산이 임계 구역에 대한 상호 배타적인 접근을 보장합니다.
badcnt.c 수정하기 (goodcnt.c)요약하자면, Figure 12.16의 예제 카운터 프로그램을 세마포를 사용해 올바르게 동기화하려면, 먼저 mutex라는 세마포를 선언합니다.
volatile long cnt = 0; /* 카운터 */
sem_t mutex; /* 카운터를 보호할 세마포 */
그리고 main 루틴에서 이 세마포를 1로 초기화합니다.
Sem_init(&mutex, 0, 1); /* mutex = 1 */
마지막으로, 스레드 루틴에서 공유 변수 cnt를 업데이트하는 부분을 P와 V 연산으로 감싸줍니다.
for (i = 0; i < niters; i++) {
P(&mutex);
cnt++;
V(&mutex);
}
이렇게 올바르게 동기화된 프로그램을 실행하면, 이제 매번 정확한 결과가 나옵니다.
linux> ./goodcnt 1000000
OK cnt=2000000
linux> ./goodcnt 1000000
OK cnt=2000000
진행 그래프(Progress graph)는 단일 프로세서(uniprocessor)에서의 동시성 프로그램 실행을 시각화하고, 왜 동기화가 필요한지 이해하는 데 좋은 방법을 제공합니다.
하지만 이 그래프는 한계가 있습니다. 특히 멀티프로세서(multiprocessor) (여러 CPU/캐시 쌍이 동일한 메인 메모리를 공유하는 환경)에서의 동시 실행을 설명하는 데 한계가 있습니다.
멀티프로세서는 진행 그래프로 설명할 수 없는 방식으로 동작합니다. 구체적으로, 멀티프로세서 메모리 시스템은 진행 그래프 상의 어떤 경로(trajectory)에도 해당하지 않는 상태에 있을 수 있습니다.
그럼에도 불구하고, 결론은 동일합니다:
단일 프로세서에서 실행하든, 멀티프로세서에서 실행하든 상관없이, 공유 변수에 대한 접근은 항상 동기화해야 합니다.

상호 배제를 제공하는 것 외에, 세마포의 또 다른 중요한 용도는 공유 자원에 대한 접근을 스케줄링(scheduling)하는 것입니다.
이 시나리오에서, 한 스레드는 프로그램의 어떤 상태 조건이 참(true)이 되었음을 다른 스레드에게 알리기 위해(notify) 세마포 연산을 사용합니다.
이에 대한 고전적이고 유용한 두 가지 예시로 생산자-소비자(producer-consumer) 문제와 읽기-쓰기(readers-writers) 문제가 있습니다.
이 문제는 n개의 슬롯을 가진 공유 유한 버퍼(bounded buffer)를 생산자(Producer) 스레드와 소비자(Consumer) 스레드가 공유하는 상황을 다룹니다.
이 문제를 해결하기 위해서는 두 가지 동기화가 필요합니다.
front, rear 인덱스)를 수정하는 작업은 원자적(atomic)으로 일어나야 합니다.(예: 멀티미디어 인코더(생산자)와 디코더(소비자), GUI의 이벤트 탐지(생산자)와 화면 그리기(소비자))
Figure 12.24와 12.25의 Sbuf 패키지는 이 문제를 세 개의 세마포를 이용해 우아하게 해결합니다.

sbuf_t 구조체는 다음 3개의 세마포를 가집니다.
mutex (뮤텍스): (초기값: 1) 버퍼 접근에 대한 상호 배제를 보장하는 이진 세마포입니다.slots (슬롯): (초기값: ) 사용 가능한 빈 슬롯의 개수를 세는 카운팅 세마포입니다.items (아이템): (초기값: 0) 버퍼에 들어있는 아이템의 개수를 세는 카운팅 세마포입니다.sbuf_insert (생산자) 함수
void sbuf_insert(sbuf_t *sp, int item) {
P(&sp->slots); // 1. 빈 슬롯을 기다린다. (slots--. 0이면 대기)
P(&sp->mutex); // 2. 버퍼를 잠근다. (상호 배제)
// (버퍼에 아이템 삽입)
V(&sp->mutex); // 3. 버퍼 잠금을 해제한다.
V(&sp->items); // 4. 새 아이템이 생겼다고 알린다. (items++)
}
sbuf_remove (소비자) 함수
int sbuf_remove(sbuf_t *sp) {
int item;
P(&sp->items); // 1. 아이템을 기다린다. (items--. 0이면 대기)
P(&sp->mutex); // 2. 버퍼를 잠근다. (상호 배제)
// (버퍼에서 아이템 제거)
V(&sp->mutex); // 3. 버퍼 잠금을 해제한다.
V(&sp->slots); // 4. 빈 슬롯이 생겼다고 알린다. (slots++)
return item;
}
이 문제는 읽기 스레드(Readers)와 쓰기 스레드(Writers)가 공유 객체에 접근할 때 발생합니다.
(예: 항공사 예약 시스템 (다수가 좌석 조회, 한 명만 예약), 웹 프록시 캐시 (다수가 캐시 읽기, 한 스레드만 캐시 쓰기))
이 해결책은 2개의 세마포와 1개의 카운트 변수를 사용합니다.
w (쓰기 락): (초기값: 1) 공유 객체 자체에 대한 접근을 제어합니다. (읽기/쓰기 모두 사용)mutex (뮤텍스): (초기값: 1) readcnt 변수를 보호하기 위한 상호 배제 락입니다.readcnt (카운터): (초기값: 0) 현재 임계 구역에 있는 읽기 스레드의 수를 셉니다.
P(w); // 객체에 대한 독점 락을 획득
// (쓰기 작업 수행)
V(w); // 객체 락 해제

P(mutex); // readcnt를 잠근다
readcnt++; // 읽기 스레드 수 증가
if (readcnt == 1) // (중요) 내가 "첫 번째" 읽기 스레드라면
P(w); // 쓰기 스레드를 막기 위해 객체 락을 획득
V(mutex); // readcnt 잠금 해제
// (읽기 작업 수행)
P(mutex); // readcnt를 잠근다
readcnt--; // 읽기 스레드 수 감소
if (readcnt == 0) // (중요) 내가 "마지막" 읽기 스레드라면
V(w); // 쓰기 스레드가 진입할 수 있도록 객체 락을 해제
V(mutex); // readcnt 잠금 해제
이 방식에서, w 세마포는 첫 번째 읽기 스레드가 락을 잡고, 마지막 읽기 스레드가 락을 해제합니다. 중간에 있는 읽기 스레드들(readcnt가 1 이상일 때)은 w 락을 무시하고 자유롭게 진입합니다.
지금까지 세마포가 공유 변수 접근(상호 배제)과 공유 자원 접근 스케줄링에 어떻게 사용되는지 살펴보았습니다. 이 아이디어들을 미리 스레드 생성하기(prethreading)라는 기법에 기반한 동시성 서버에 적용해 보겠습니다.
pthread_create)했습니다. 이 접근 방식은 클라이언트가 새로 올 때마다 스레드를 생성하는 상당한 (nontrivial) 오버헤드(비용)를 유발합니다.
Accept)하고, 그 결과로 생성된 연결 디스크립터(connfd)를 유한 버퍼(bounded buffer)에 삽입(Insert)합니다.connfd를 반복적으로 제거(Remove)하고, 해당 클라이언트를 서비스(service)한 후, 다음 connfd를 기다립니다.echoserver-pre.c 구현 (Figure 12.28)
Figure 12.28은 Sbuf 패키지(이전 섹션의 생산자-소비자 버퍼)를 사용하여 prethreaded 동시성 에코 서버를 구현하는 방법을 보여줍니다.
main 스레드 (생산자):sbuf_init(line 24): 공유 버퍼 sbuf를 초기화합니다.for 루프 (lines 25–26): NTHREADS 개수만큼 워커 스레드들을 미리 생성합니다.while (1) (lines 28–31): 메인 스레드는 무한 루프에 진입하여, Accept (연결 수락)와 sbuf_insert (버퍼에 connfd 삽입) 작업만 반복 수행합니다.thread (워커 스레드 루틴):Pthread_detach(line 36): 스레드가 join될 필요 없도록 스스로를 분리(detach)합니다.while (1) (lines 38–42):connfd = sbuf_remove(&sbuf) (line 39): Sbuf에서 connfd를 꺼냅니다. (만약 버퍼가 비어있으면, sbuf_remove 내부의 P(&items)에서 블록(block)됩니다.)echo_cnt(connfd) (line 40): 클라이언트 서비스 함수를 호출합니다.Close(connfd) (line 41): 서비스가 끝나면 연결을 닫습니다.connfd를 기다립니다.)echo_cnt 함수: 스레드 안전한 패키지 설계 (Figure 12.29)
echo_cnt 함수는 모든 클라이언트로부터 받은 누적 바이트 수를 byte_cnt라는 전역 변수에 기록하는 echo 버전입니다. 이 코드는 스레드 루틴에서 호출되는 패키지를 초기화하는 일반적인 기법을 보여주므로 중요합니다.
여기서는 byte_cnt 카운터와 mutex 세마포를 초기화해야 합니다.
pthread_once를 이용한 초기화init 함수를 호출하는 대신) pthread_once 함수(line 19)를 사용합니다.pthread_once는 어떤 스레드든 echo_cnt를 최초로 호출했을 때, 초기화 함수(init_echo_cnt)가 단 한 번만 실행되도록 보장합니다.init 호출을 신경 쓸 필요 없음)echo_cnt가 호출될 때마다 (대부분 쓸데없는) pthread_once 함수를 호출하는 비용이 듭니다.P/V를 이용한 상호 배제init_echo_cnt가 실행되면, byte_cnt는 0, mutex는 1로 초기화됩니다.echo_cnt가 실제 작업을 할 때, 공유 변수 byte_cnt에 접근하는 부분(lines 23–25)은 P(&mutex)와 V(&mutex) 연산으로 감싸져 보호됩니다.badcnt.c에서 발생했던 경쟁 상태(race condition)를 방지하고 byte_cnt가 올바르게 증가하도록 보장합니다.(Aside: Other synchronization mechanisms)
우리는 주로 세마포(semaphores)를 사용하여 스레드를 동기화하는 방법을 보여드렸습니다. 세마포가 단순하고 고전적이며 깔끔한 의미론적 모델을 가지고 있기 때문입니다.
하지만 다른 동기화 기법들도 존재한다는 것을 알아야 합니다.
(Aside: Event-driven programs based on threads)
I/O 멀티플렉싱만이 이벤트 기반(event-driven) 프로그램을 작성하는 유일한 방법은 아닙니다.
예를 들어, 우리가 방금 개발한 동시성 prethreaded 서버 (12.5.5절) 역시 메인 스레드와 워커 스레드를 위한 간단한 상태 머신을 가진 이벤트 기반 서버라는 것을 눈치챘을 것입니다.
지금까지 우리는 단일 프로세서(uniprocessor) 시스템에서의 동시성 스레드를 가정했습니다. 하지만 대부분의 최신 머신은 멀티코어(multi-core) 프로세서를 가지고 있습니다.
동시성 프로그램은 이런 머신에서 더 빠르게 실행되는 경우가 많습니다. 왜냐하면 운영체제 커널이 동시성 스레드들을 단일 코어에서 순차적(sequentially)으로 실행하는 대신, 여러 코어에서 병렬(in parallel)로 스케줄링하기 때문입니다.
이러한 병렬성(parallelism)을 활용하는 것은 웹 서버, 데이터베이스 서버, 대규모 과학 계산 코드 등에서 매우 중요하며, 웹 브라우저나 스프레드시트 같은 일반 프로그램에서도 점점 더 유용해지고 있습니다.
Figure 12.30은 순차, 동시성, 병렬 프로그램 간의 집합 관계를 보여줍니다.

병렬 프로그래밍의 몇 가지 중요한 측면을 이해하기 위해, 부터 까지의 정수 합계를 병렬로 계산하는 간단한 예제를 살펴봅니다.
가장 간단한 접근 방식은 전체 시퀀스를 개의 영역으로 분할하고, 개의 스레드가 각자 자신의 영역을 맡아 작업하게 하는 것입니다.


가장 직관적인 방법은, 모든 스레드가 뮤텍스(mutex)로 보호되는 단일 공유 전역 변수(gsum)에 합계를 더하는 것입니다. (Figure 12.31, 12.32)
main 스레드: t개의 피어 스레드를 생성하고, 각 스레드에 고유한 ID(0, 1, 2...)를 전달합니다. 스레드가 모두 종료되길 기다린(pthread_join) 후, gsum 값을 확인합니다.sum_mutex (피어 스레드):for 루프의 모든 반복마다 P(&mutex)로 락을 걸고, gsum += i;를 실행한 뒤, V(&mutex)로 락을 풉니다.성능 측정 결과 (4코어, ):
| 쓰레드 개수 | 1 | 2 | 4 | 8 | 16 |
|---|---|---|---|---|---|
| psum-mutex | 68 | 432 | 719 | 552 | 599 |
끔찍한 놀라움을 마주하게 됩니다.
이유:동기화 오버헤드(Synchronization overhead) 때문입니다. P(락)와 V(언락) 연산은 단순한 메모리 업데이트(gsum += i) 비용에 비해 매우 비쌉니다.
교훈 1: 동기화 오버헤드는 비싸므로 가능하면 피해야 합니다. 피할 수 없다면, 가능한 한 많은 유용한 계산을 통해 그 오버헤드를 상각(amortize)해야 합니다.
동기화를 피하는 한 가지 방법은, 각 피어 스레드가 자신의 부분 합계를 다른 스레드와 공유되지 않는 개별 변수에 계산하도록 하는 것입니다. (Figure 12.33)

| 쓰레드 개수 | 1 | 2 | 4 | 8 | 16 |
|---|---|---|---|---|---|
| psum-mutex | 68.00 | 432.00 | 719.00 | 552.00 | 599.00 |
| psum-array | 7.26 | 3.64 | 1.91 | 1.85 | 1.84 |
main 스레드: psum이라는 전역 배열을 정의합니다.sum_array (피어 스레드):psum[i]에 누적시킵니다. (psum[myid] += i;)psum[0], psum[1]...)를 업데이트하므로, 뮤텍스 보호가 필요 없습니다.main 스레드: 모든 스레드가 종료되면, psum 벡터의 모든 요소를 합산하여 최종 결과를 얻습니다.성능 측정 결과:
psum-array는 psum-mutex보다 수백 배(orders of magnitude) 더 빠릅니다. 스레드가 4개일 때 1.91초로, 병렬성의 이점을 제대로 보여줍니다.
(5장에서 배운) 불필요한 메모리 참조를 제거하는 원칙을 적용하여, 각 스레드가 전역 변수가 아닌 지역 변수(local variable)에 부분 합계를 누적하도록 할 수 있습니다. (Figure 12.34)

sum_local (피어 스레드):for 루프 안에서는 스택에 할당된 지역 변수 sum에 합계를 누적합니다. (이 변수는 레지스터에 저장될 가능성이 높습니다.)for (i = start; i < end; i++) { sum += i; }psum에 자신의 결과를 저장합니다. (psum[myid] = sum;)성능 측정 결과:
| 쓰레드 개수 | 1 | 2 | 4 | 8 | 16 |
|---|---|---|---|---|---|
| psum-mutex | 68.00 | 432.00 | 719.00 | 552.00 | 599.00 |
| psum-array | 7.26 | 3.64 | 1.91 | 1.85 | 1.84 |
| psum-local | 1.06 | 0.54 | 0.28 | 0.29 | 0.30 |
psum-local을 실행하면 또 한 차례(another order-of-magnitude) 성능이 향상됩니다. (4개 스레드: 0.28초)
이 실습에서 얻을 수 있는 중요한 교훈은 병렬 프로그램을 작성하는 것이 매우 까다롭다(tricky)는 것입니다.
코드의 사소해 보이는 작은 변경이 성능에 막대한 영향을 미칩니다. (예: psum[myid] += i를 sum += i로 바꾸는 것)

Figure 12.35는 4개의 프로세서 코어를 가진 시스템에서 psum-local 프로그램의 실행 시간을 스레드 수()에 따라 보여줍니다.

절대적인 실행 시간도 중요하지만, 병렬성의 잠재력을 얼마나 잘 활용하는지 파악하기 위한 유용한 상대적 측정값들이 있습니다.
$T_1$을 무엇으로 보느냐에 따라 두 가지로 나뉩니다.
$T_1$ = 순차 버전 프로그램의 실행 시간.$T_1$ = 병렬 버전 프로그램을 1개 코어로 실행했을 때의 시간.psum 문제가 병렬화하기 "지나치게 쉬운" 문제였기 때문이라는 점을 감안해야 합니다.)속도 향상을 보는 관점은 두 가지가 있습니다.
공유 데이터에 대한 접근을 동기화하라는 요구를 받자마자 삶이 훨씬 더 복잡해졌다는 것을 눈치채셨을 것입니다. 지금까지 우리는 상호 배제와 생산자-소비자 동기화 기법을 살펴보았지만, 이것은 빙산의 일각에 불과합니다.
동기화는 일반적인 순차 프로그램에서는 전혀 발생하지 않는 이슈를 제기하는, 근본적으로 어려운 문제입니다. 이 섹션은 여러분이 동시성 프로그램을 작성할 때 알아야 할 몇 가지 이슈들에 대한 (결코 완전하지는 않은) 개요(survey)입니다.
논의를 구체적으로 유지하기 위해 스레드 관점에서 설명하겠지만, 이 문제들은 어떤 종류의 동시성 흐름이든 공유 자원을 조작할 때 발생하는 전형적인 이슈들임을 명심해야 합니다.
스레드로 프로그래밍할 때는 스레드 안전성(thread safety)이라는 속성을 가진 함수를 작성하도록 주의해야 합니다.
어떤 함수가 여러 동시성 스레드에서 반복적으로 호출될 때 항상 올바른 결과를 생성한다면, 그 함수는 스레드 안전(thread-safe)하다고 말합니다. 만약 그렇지 않다면, 스레드 비안전(thread-unsafe)하다고 말합니다.
스레드 비안전 함수는 (서로 배타적이지 않은) 4가지 클래스로 식별할 수 있습니다.
badcnt.c의 cnt++)P/V 같은 동기화 연산(뮤텍스)으로 공유 변수를 보호합니다.
rand 함수. next_seed라는 정적(static) 변수(공유 자원)를 사용하여 다음 난수를 생성합니다. 여러 스레드가 동시에 rand를 호출하면 next_seed 값이 뒤엉켜버립니다.rand_r 함수 참고)
/* 스레드 A의 루틴 */
void *thread_A_routine(void *vargp) {
unsigned int my_seed = 1; // A의 개인 씨앗 상자 (스택에 있음)
while (1) {
int r = rand_r(&my_seed); // A의 상자 주소를 넘김
}
}
/* 스레드 B의 루틴 */
void *thread_B_routine(void *vargp) {
unsigned int my_seed = 99; // B의 개인 씨앗 상자 (스택에 있음)
while (1) {
int r = rand_r(&my_seed); // B의 상자 주소를 넘김
}
}
ctime이나 gethostbyname 같은 일부 함수는, (공유되는) 단 하나의 정적 변수에 결과를 계산한 뒤, 그 변수의 포인터를 반환합니다.strcpy)합니다.
ctime_ts는 ctime 함수를 이 기법으로 감싼 스레드 안전 래퍼 함수(wrapper function)입니다.)ctime_ts가 바로 이 예시입니다.)재진입 가능 함수(reentrant functions)는 스레드 안전(thread-safe) 함수 중에서도 중요한 한 클래스입니다. 이 함수들은 여러 스레드에 의해 호출될 때 어떠한 공유 데이터도 참조하지 않는다는 속성을 특징으로 합니다.
"스레드 안전"과 "재진입 가능"이라는 용어가 (부정확하게) 동의어처럼 사용되기도 하지만, 보존할 가치가 있는 명확한 기술적 구분이 있습니다. Figure 12.39는 이들의 집합 관계를 보여줍니다.

재진입 가능 함수는 동기화 연산(synchronization operations)이 전혀 필요 없기 때문에, 비-재진입(non-reentrant) 스레드 안전 함수보다 일반적으로 더 효율적입니다.
또한, (여러 호출에 걸쳐 상태를 유지하는) 클래스 2 스레드 비안전 함수를 스레드 안전하게 만드는 유일한 방법은 그 함수를 재진입 가능하도록 다시 작성하는 것입니다.
rand 함수를 Figure 12.40의 rand_r 함수로 바꾼 것이 그 예입니다.
next_seed를 사용하는 대신, 호출자가 전달하는 포인터(nextp)를 사용하도록 변경했습니다.어떤 함수의 코드를 보고 그것이 재진입 가능하다고 미리 선언할 수 있을까요? 상황에 따라 다릅니다.
rand_r 함수가 "암시적 재진입 가능"입니다. (스레드들이 각자 고유한 seed 변수의 주소를 nextp로 넘겨줘야만 재진입성이 보장됩니다.)우리는 보통 "재진입 가능"이라는 용어를 명시적, 암시적 재진입을 모두 포함하여 사용합니다. 하지만 재진입성은 때때로 함수 자체(callee)만의 속성이 아니라, 호출자(caller)와 함수(callee) 양쪽 모두의 속성이라는 점을 인지하는 것이 중요합니다.
대부분의 리눅스 함수 및 표준 C 라이브러리 함수(malloc, free, realloc, printf, scanf 등)는 스레드 안전(thread-safe)합니다.
하지만 몇 가지 예외가 있으며, Figure 12.41은 그 일반적인 예외 목록을 보여줍니다.

strtok: (사용이 권장되지 않는) 문자열 파싱 함수 (클래스 2)asctime, ctime, localtime: 날짜/시간 변환 함수 (클래스 3)gethostbyaddr, gethostbyname, inet_ntoa: (구식의) 네트워크 프로그래밍 함수 (클래스 3)rand와 strtok을 제외한 이 함수들은 대부분 정적 변수의 포인터를 반환하는 클래스 3 스레드 비안전 함수입니다.
스레드 프로그램에서 이 함수들을 호출해야 할 때, 호출자에게 가장 지장을 덜 주는 접근 방식은 "잠금-복사(lock-and-copy)"입니다. (12.7.1의 ctime_ts 래퍼 함수처럼)
하지만 이 방식은 여러 단점이 있습니다.
rand와 같이 호출 간 상태를 유지하는 클래스 2 함수에는 이 방식이 동작하지 않습니다.따라서, 리눅스 시스템은 대부분의 스레드 비안전 함수에 대한 재진입 가능(reentrant) 버전을 제공합니다.
_r 접미사로 끝납니다. (예: asctime의 재진입 버전은 asctime_r입니다.)gethost... 함수들은 getaddrinfo나 getnameinfo 같은 최신 재진입 함수들로 대체되었습니다.)결론: 가능하면 항상 이 _r 버전의 함수들을 사용할 것을 권장합니다.
경쟁(race)은 한 스레드가 제어 흐름의 지점에 도달하기 전에 다른 스레드가 지점에 도달하는 것에 프로그램의 정확성이 의존할 때 발생합니다.
경쟁은 프로그래머가 스레드가 특정 실행 경로로만 실행될 것이라고 가정하고, "스레드 프로그램은 가능한 모든 실행 경로에 대해 올바르게 작동해야 한다"는 황금률을 잊어버리기 때문에 발생합니다.
race.c)
Figure 12.42의 프로그램은 4개의 피어 스레드를 생성하고, 각 스레드에 고유한 정수 ID의 포인터를 전달합니다. 각 피어 스레드는 전달받은 ID를 지역 변수 myid에 복사(line 22)한 뒤 메시지를 출력합니다.
간단해 보이지만, 이 프로그램을 실행하면 다음과 같은 잘못된 결과가 나옵니다. (3이 두 번 나옴)
linux> ./race
Hello from thread 1
Hello from thread 3
Hello from thread 2
Hello from thread 3
main 스레드와 peer 스레드 간의 경쟁이 문제는 각 피어 스레드와 메인 스레드 간의 경쟁 때문에 발생합니다.
main 스레드는 for 루프를 돌며 스레드를 생성할 때(line 13), main 스레드의 지역 스택 변수 i의 주소(&i)를 전달합니다.
&i는 모든 스레드에게 동일한 단 하나의 메모리 주소입니다.
이 시점에서, 다음 두 작업 간의 경쟁이 시작됩니다.
main 스레드: for 루프가 다음으로 돌아 i를 증가시키는 작업 (line 12, i++)peer 스레드: 인자를 역참조하여 myid에 할당하는 작업 (line 22, myid = *((int *)vargp))만약 peer 스레드가 line 22를 main 스레드가 line 12보다 먼저 실행하면 myid는 올바른 값을 갖습니다. 하지만 그 반대라면 (즉, main 스레드가 i를 다음 값으로 덮어쓴 후에 peer 스레드가 line 22를 실행하면), myid는 다른 스레드의 ID 값을 갖게 됩니다. (위 예시에서는 스레드 2개가 3을 가져갔습니다.)
이 문제의 무서운 점은, 커널이 스레드를 어떻게 스케줄링하느냐에 따라 정답이 나올 수도 있고 아닐 수도 있다는 것입니다. (한 시스템에서는 실패하지만, 다른 시스템에서는 우연히 성공하여 프로그래머가 심각한 버그를 모른 채 넘어갈 수 있습니다.)
norace.c)
경쟁을 제거하려면, 각 정수 ID마다 별도의 블록을 동적 할당(malloc)하고, 스레드 루틴에 이 블록의 포인터를 전달해야 합니다. (lines 12–14)
/* main 함수 (Figure 12.43) */
for (i = 0; i < N; i++) {
ptr = Malloc(sizeof(int)); // 1. 스레드마다 고유한 메모리 블록 할당
*ptr = i; // 2. 그 고유한 블록에 i 값을 저장
Pthread_create(&tid[i], NULL, thread, ptr); // 3. 고유한 블록의 주소(ptr) 전달
}
/* thread 루틴 (Figure 12.43) */
void *thread(void *vargp) {
int myid = *((int *)vargp); // 4. 고유한 주소에서 값을 읽음
Free(vargp); // 5. 할당된 메모리 블록을 해제 (메모리 누수 방지)
printf("Hello from thread %d\n", myid);
return NULL;
}
이 프로그램을 실행하면, 이제 매번 올바른 결과가 나옵니다.
linux> ./norace
Hello from thread 0
Hello from thread 1
Hello from thread 2
Hello from thread 3
세마포는 교착 상태(deadlock)라고 불리는 고약한 런타임 오류의 가능성을 도입합니다. 교착 상태란 여러 스레드(집합)가 절대 참이 될 수 없는 조건을 기다리며 블록(blocked)되는 상황을 말합니다.
진행 그래프(Progress graph)는 교착 상태를 이해하는 데 매우 유용한 도구입니다. Figure 12.44는 두 개의 세마포()를 상호 배제에 사용하는 스레드 쌍의 진행 그래프를 보여줍니다.

이 그래프에서 교착 상태에 대한 몇 가지 중요한 통찰을 얻을 수 있습니다.

프로그램이 교착 상태에 빠지는 이유는 많으며, 이를 예방하는 것은 일반적으로 어려운 문제입니다.
하지만, Figure 12.44처럼 상호 배제를 위해 이진 세마포(뮤텍스)를 사용하는 경우, 교착 상태를 예방하는 다음과 같은 간단하고 효과적인 규칙을 적용할 수 있습니다.
뮤텍스 락 순서 규칙 (Mutex lock ordering rule):
모든 뮤텍스에 대해 전체 순서(total ordering)를 정하고, 각 스레드가 이 순서대로 뮤텍스를 획득하고, 그 역순으로 뮤텍스를 해제한다면, 그 프로그램은 교착 상태가 발생하지 않습니다(deadlock-free).
예를 들어, Figure 12.44의 교착 상태는 두 스레드 모두 를 먼저 락(lock)하고 를 다음에 락하도록 수정함으로써 해결할 수 있습니다. Figure 12.45는 그 결과(금지된 영역이 겹치지 않음)를 보여줍니다.
이 챕터에서는 시간적으로 겹치는 여러 논리적 흐름을 갖는 동시성 프로그램을 구축하는 세 가지 메커니즘을 학습했습니다.
select 등을 통해 흐름을 명시적으로 스케줄링합니다.어떤 메커니즘을 사용하든, 공유 데이터에 대한 동시 접근을 동기화하는 것은 근본적으로 어려운 문제입니다.
P (잠금/대기) 연산과 V (해제/신호) 연산을 통해 이 문제를 해결하도록 돕습니다.badcnt.c 같은 임계 구역을 보호합니다.동기화 외에도 동시성 프로그램은 여러 어려운 문제를 야기합니다.
_r 버전) 함수로 다시 작성해야 합니다.main의 i++ vs thread의 vargp), 프로그램의 정확성이 스케줄링에 의존하게 되는 버그입니다.