Proven C BookEnglish GitHub

79 갈래를 나누어 돌리기 — <threads.h>

먼저 알아야 할 것

12장 속도의 기계장치 · 클록의 한계가 부른 멀티코어
44장 수명과 저장 기간 · 무엇이 공유되고 무엇이 갈래마다 따로인가
53장 main의 세 얼굴 · 프로그램이 시작하고 끝나는 자리

돌아보기

12장에서 클록을 더 올릴 수 없게 되자 코어를 여럿 두는 쪽으로 갔다고 했다. 그런데 지금까지 이 책의 모든 프로그램은 갈래가 하나였다. 코어가 여덟 개인 기계에서 그 프로그램은 무엇을 하고 있었는가?

답. 일곱 개를 놀리고 있었다. 그리고 그것이 잘못은 아니다 — 갈래를 늘리는 순간 치러야 할 값이 생기기 때문이다. 이 장이 그 값을 보여 준다. 갈래를 만드는 일 자체는 함수 두 개면 되는데, 같은 기억을 둘이 만지는 순간 이제까지 배운 「읽고 계산하고 쓴다」가 통째로 흔들린다. 그 흔들림을 눈으로 확인하는 것이 이 장의 절반이고, 나머지 절반은 잠금과 조건 변수로 그것을 다시 세우는 일이다.

이 장의 필요성과 맥락

11부의 다른 장들이 이미 쓰고 있던 것을 정독했다면 이 장은 한 번도 쓰지 않은 것을 연다. 그런데도 여기 있는 이유는 다음 장 때문이다 — 80장의 원자적 연산은 「갈래가 여럿일 때」라야 뜻이 있는데, 갈래를 만들 줄 모르면 그 장의 시연이 남의 이야기가 된다. 그래서 만드는 법을 앞에, 공유를 안전하게 하는 법을 뒤에 둔다. 바닥은 이미 깔려 있다 — 12장이 멀티코어를, 44장이 「무엇이 공유되는가」를, 11장이 코어마다 따로인 캐시를 세워 두었다.

이 장이 끝나면

갈래(스레드)를 만들고, 기다리고, 값을 돌려받는 법을 익힌다. 그다음 보호 없이 같은 칸을 만졌을 때 무슨 일이 실제로 일어나는지를 실측으로 본다. 잠금(mtx_*), 조건 변수(cnd_*), 한 번만 실행(call_once), 갈래마다 따로인 저장소까지 갖추면 표준이 정한 동시성 어휘가 완성된다. 마지막으로 왜 실무는 여전히 pthread 를 쓰는가를 짚는다 — 78장의 부속서 K 에 이은, 「표준에 들어갔다고 이기는 것은 아니다」의 두 번째 사례다.

이 장에서 답할 질문

  1. 그런데 이 헤더는 없어도 되는 헤더라고 들었다.
  2. 그러면 이 장은 왜 배우는가?

79.1 왜 표준에 스레드가 들어왔나

C89 에도 C99 에도 스레드가 없었다. 갈래를 나누고 싶으면 운영체제에 부탁해야 했고, 그 부탁의 말이 유닉스에서는 pthread, 윈도우에서는 CreateThread 였다. 같은 프로그램을 두 곳에서 돌리려면 두 벌을 쓰거나 자기만의 껍질을 씌워야 했다.

12장에서 본 그대로다 — 2000년대 중반 클록이 벽에 부딪히자 성능은 코어 수로 왔고, 그러자 「갈래를 나눈다」가 특수한 기술에서 평범한 필요가 되었다. C11 이 <threads.h> 를 들인 것이 그 대답이다. 같은 판에서 <stdatomic.h>(80장)도 함께 들어왔다 — 갈래를 만드는 어휘와 공유를 다스리는 어휘를 한꺼번에 준 셈이다.

문. 그런데 이 헤더는 없어도 되는 헤더라고 들었다.

답. 그렇다. 표준이 그렇게 적어 두었다 — 구현이 __STDC_NO_THREADS__ 를 정의하면 <threads.h> 를 주지 않아도 적합하다. 이 책의 예제가 그 매크로를 먼저 살피는 이유가 그것이다.

★ 이것이 이 장 끝에서 다룰 논지의 씨앗이다. 표준이 스스로 「없어도 좋다」고 적은 기능은 라이브러리가 기대기 어렵다. 있는 곳에서만 도는 코드를 쓸 수는 없으니, 결국 조건부 컴파일을 하거나 — 그냥 pthread 를 쓰게 된다.

79.2 첫 갈래 — 만들고 기다리기

examples/ch79/threads_basic.c

/* 갈래를 나누어 돌리기 — 만들고, 기다리고, 그리고 값이 어긋나는 것을 본다. */
#include <stdio.h>
#include <stdlib.h>

#ifdef __STDC_NO_THREADS__
/* 표준이 「없어도 적합」이라고 적어 둔 헤더다. 없는 구현에서도 이 파일은 컴파일된다. */
int main(void) { puts("this implementation does not provide <threads.h>"); return 0; }
#else
#include <threads.h>

/* ── 스레드 함수의 꼴은 하나뿐이다: int (*)(void *) ───────────── */
static int greet(void *arg)
{
    const char *who = arg;
    printf("  hello from %s\n", who);
    return 7;                       /* 이 값은 thrd_join 이 받아 간다 */
}

/* ── 경쟁을 재현한다 — 보호 없이 같은 칸을 만진다 ─────────────────
   ★ volatile 을 붙인 이유는 「고치려고」가 아니다. 붙이지 않으면 컴파일러가
   루프 전체를 레지스터 하나로 접어 버려서 *경쟁이 일어날 틈이 없어진다*
   (실제로 그렇게 되어 아무것도 잃지 않았다). volatile 은 매 반복을 진짜
   읽기-쓰기로 만들 뿐, 갱신을 안전하게 하지 않는다 — 다음 장이 그 이야기다. */
#define BUMPS 200000
static volatile long shared;

static int bump(void *arg)
{
    (void)arg;
    for (int i = 0; i < BUMPS; i++) shared += 1;   /* 읽고 · 더하고 · 쓴다 */
    return 0;
}

int main(void)
{
    puts("[1] one thread, one return value");
    thrd_t t;
    if (thrd_create(&t, greet, "the worker") != thrd_success) {
        fputs("thrd_create failed\n", stderr);
        return EXIT_FAILURE;
    }
    int rc = 0;
    thrd_join(t, &rc);                   /* 끝날 때까지 기다리고 값을 받는다 */
    printf("  the worker returned %d\n\n", rc);

    puts("[2] four threads bumping one counter, unprotected");
    enum { N = 4 };
    thrd_t w[N];
    shared = 0;
    for (int i = 0; i < N; i++) thrd_create(&w[i], bump, NULL);
    for (int i = 0; i < N; i++) thrd_join(w[i], NULL);

    long expected = (long)N * BUMPS, got = shared;
    printf("  expected %ld\n", expected);
    printf("  actual   %ld%s\n", got, got == expected ? "" : "   <- updates were lost");
    puts("  (the number differs on every run --- that is what a race looks like)");
    return 0;
}
#endif

실행 결과

[1] one thread, one return value
  hello from the worker
  the worker returned 7

[2] four threads bumping one counter, unprotected
  expected 800000
  actual   228578   <- updates were lost
  (the number differs on every run --- that is what a race looks like)

출력의 첫 묶음이 어휘의 전부다.

스레드 함수의 꼴은 하나뿐이다int (*)(void *). 인자도 반환도 하나씩이고, 넘기고 싶은 것이 여럿이면 구조체를 하나 만들어 그 주소를 준다(46장). 반환한 intthrd_join 의 둘째 인자로 나온다.

함수하는 일알아 둘 것
thrd_create(&t, fn, arg)갈래를 만들어 곧바로 fn(arg) 를 돌린다thrd_success·thrd_nomem·thrd_error 중 하나를 돌려준다 — 확인해야 한다
thrd_join(t, &rc)끝날 때까지 기다리고 반환값을 받는다rc 가 필요 없으면 널을 준다
thrd_detach(t)기다리지 않겠다고 선언한다떼어 낸 갈래는 join 할 수 없다
thrd_current()지금 갈래의 손잡이thrd_equal 로만 견준다 — 정수가 아니다
thrd_yield()자리를 양보한다보장이 아니라 힌트다
thrd_sleep(&dur, &rem)재운다struct timespec 두 개(74장)
thrd_exit(res)지금 갈래만 끝낸다main 에서 부르면 프로그램이 끝나지 않는다 — 다른 갈래를 기다린다

표 79.1

thrd_t 를 정수처럼 다루지 않는다. 표준은 그것이 무엇인지 정해 주지 않았고, 비교는 thrd_equal 로만 한다. 인쇄할 방법도 표준에는 없다.

79.3 그런데 값이 어긋난다

예제의 둘째 묶음이 이 장에서 가장 중요한 화면이다. 갈래 넷이 각각 20만 번씩 shared += 1 을 했으니 80만이어야 하는데, 이 책이 다섯 번 돌려 얻은 값은 23만~29만이었다. 셋 중 둘꼴로 사라졌다.

원인은 한 줄이 한 걸음이 아니기 때문이다.

shared += 1 이 실제로 하는 일두 갈래가 겹치면
① 기억에서 값을 읽는다둘 다 100 을 읽고
② 1 을 더한다둘 다 101 을 만들고
③ 기억에 쓴다둘 다 101 을 쓴다 — 두 번 더했는데 하나만 늘었다

표 79.2

흔한 오해. += 1 은 한 번에 일어나니 안전하다”

소스에서 한 줄이라는 것과 기계에서 한 걸음이라는 것은 별개다(13장). 게다가 한 걸음이라 해도 안전하지 않다 — 코어마다 자기 캐시를 들고 있어서(11장) 「누가 먼저 썼는가」조차 정해지지 않는다.

★ 표준의 낱말로는 이것이 데이터 경쟁(data race)이고, 데이터 경쟁은 미정의 동작이다. 「값이 좀 틀린다」가 아니라 무엇이든 일어날 수 있다는 뜻이다(52장).

실제 사례. 경쟁을 재현하려다 알게 된 것 — 컴파일러가 먼저 지웠다

이 예제를 처음 썼을 때 한 번도 값이 어긋나지 않았다. 다섯 번 돌려 다섯 번 다 정확히 800000 이었다.

까닭은 경쟁이 없어서가 아니라 경쟁할 틈이 없어서였다. 컴파일러가 20만 번의 루프를 레지스터 하나에 접어 「한 번에 200000 더하기」로 바꿔 버린 것이다(13장). 각 갈래가 기억을 만지는 것은 사실상 한 번뿐이니 겹칠 일이 없었다.

그래서 예제의 sharedvolatile 을 붙였다. ★ 고치려고 붙인 것이 아니다 — 매 반복을 진짜 읽기·쓰기로 만들어 경쟁이 일어나도록 붙인 것이다. volatile 은 갱신을 안전하게 하지 않는다. 왜 그런지가 바로 다음 장의 첫 절이다.

79.4 상호 배제 — mtx_*

examples/ch79/threads_sync.c

/* 재우고 깨우기 — 상호 배제, 조건 변수, 한 번만, 갈래마다 따로. */
#include <stdio.h>
#include <stdlib.h>

#ifdef __STDC_NO_THREADS__
int main(void) { puts("this implementation does not provide <threads.h>"); return 0; }
#else
#include <threads.h>

/* ── ① 상호 배제 — 앞 예제의 경쟁을 잠금으로 막는다 ───────────── */
#define BUMPS 200000
static long guarded;                 /* volatile 이 필요 없다 — 잠금이 순서를 준다 */
static mtx_t lock;

static int bump_guarded(void *arg)
{
    (void)arg;
    for (int i = 0; i < BUMPS; i++) {
        mtx_lock(&lock);
        guarded += 1;
        mtx_unlock(&lock);
    }
    return 0;
}

/* ── ② 조건 변수 — 「생길 때까지 기다린다」 ────────────────────── */
static mtx_t qlock;
static cnd_t not_empty;
static int items;                    /* 아주 작은 큐: 개수만 센다 */
static bool closed;

static int consumer(void *arg)
{
    int *taken = arg;
    mtx_lock(&qlock);
    for (;;) {
        /* ★ while 이지 if 가 아니다 — 깨어났다고 조건이 참인 것은 아니다 */
        while (items == 0 && !closed)
            cnd_wait(&not_empty, &qlock);
        if (items == 0 && closed) break;
        items -= 1;
        *taken += 1;
    }
    mtx_unlock(&qlock);
    return 0;
}

/* ── ③ 한 번만 — 게으른 초기화 ────────────────────────────────── */
static once_flag once = ONCE_FLAG_INIT;
static int init_count;

static void init_table(void) { init_count += 1; }

static int touch(void *arg)
{
    (void)arg;
    call_once(&once, init_table);
    return 0;
}

/* ── ④ 갈래마다 따로 ──────────────────────────────────────────── */
static thread_local int mine;        /* C23 의 키워드 (81장) */
static int per_thread(void *arg)
{
    mine = *(int *)arg;              /* 갈래마다 제 몫이 따로 있다 */
    return mine;
}

int main(void)
{
    enum { N = 4 };
    thrd_t w[N];

    puts("[1] the same counter, now guarded by a mutex");
    mtx_init(&lock, mtx_plain);
    guarded = 0;
    for (int i = 0; i < N; i++) thrd_create(&w[i], bump_guarded, NULL);
    for (int i = 0; i < N; i++) thrd_join(w[i], NULL);
    printf("  expected %ld, actual %ld%s\n", (long)N * BUMPS, guarded,
           guarded == (long)N * BUMPS ? "   <- nothing lost, every run" : "   <- lost");
    mtx_destroy(&lock);

    puts("\n[2] a condition variable: wait until there is something");
    mtx_init(&qlock, mtx_plain);
    cnd_init(&not_empty);
    int taken = 0;
    thrd_t c;
    thrd_create(&c, consumer, &taken);
    for (int i = 0; i < 5; i++) {
        mtx_lock(&qlock);
        items += 1;
        cnd_signal(&not_empty);       /* 잠금을 쥔 채 알린다 */
        mtx_unlock(&qlock);
    }
    mtx_lock(&qlock);
    closed = true;
    cnd_broadcast(&not_empty);        /* 끝났음을 모두에게 */
    mtx_unlock(&qlock);
    thrd_join(c, NULL);
    printf("  produced 5, consumer took %d\n", taken);
    cnd_destroy(&not_empty);
    mtx_destroy(&qlock);

    puts("\n[3] call_once: four threads, one initialization");
    for (int i = 0; i < N; i++) thrd_create(&w[i], touch, NULL);
    for (int i = 0; i < N; i++) thrd_join(w[i], NULL);
    printf("  init ran %d time(s)\n", init_count);

    puts("\n[4] thread_local: each thread has its own");
    int vals[N];
    int got[N];
    for (int i = 0; i < N; i++) { vals[i] = (i + 1) * 10; thrd_create(&w[i], per_thread, &vals[i]); }
    for (int i = 0; i < N; i++) thrd_join(w[i], &got[i]);
    printf("  each thread saw:");
    for (int i = 0; i < N; i++) printf(" %d", got[i]);
    printf("\n  main's own copy is still %d\n", mine);
    return 0;
}
#endif

실행 결과

[1] the same counter, now guarded by a mutex
  expected 800000, actual 800000   <- nothing lost, every run

[2] a condition variable: wait until there is something
  produced 5, consumer took 5

[3] call_once: four threads, one initialization
  init ran 1 time(s)

[4] thread_local: each thread has its own
  each thread saw: 10 20 30 40
  main's own copy is still 0

첫 묶음이 답이다. 같은 셈을 잠금으로 감싸니 80만이 나왔고, 몇 번을 돌려도 어긋나지 않는다.

mtx_lock(&lock);
guarded += 1;
mtx_unlock(&lock);

잠금이 하는 일은 「한 번에 하나만」이다. 그리고 부수적으로 얻는 것이 하나 더 있는데 — 잠금은 순서도 준다. 그래서 guarded 에는 volatile 이 필요 없다.

종류무엇이 다른가언제
mtx_plain보통 잠금기본값. 대개 이것
mtx_timedmtx_timedlock 으로 기다리는 시간에 한도를 둘 수 있다무한정 막히면 곤란할 때
mtx_plain \| mtx_recursive같은 갈래가 여러 번 잠글 수 있다★ 대개 설계가 꼬였다는 신호
mtx_timed \| mtx_recursive둘 다

표 79.3

함수알아 둘 것
mtx_init(&m, type)쓰기 전에 반드시. 정적 초기화 방법이 표준에는 없다(POSIX 의 PTHREAD_MUTEX_INITIALIZER 같은 것)
mtx_lock(&m)잠글 때까지 기다린다
mtx_trylock(&m)이미 잠겼으면 기다리지 않고 thrd_busy
mtx_timedlock(&m, &ts)mtx_timed 로 만든 것에만. 시각은 절대 시각이다
mtx_unlock(&m)잠근 갈래가 푼다
mtx_destroy(&m)다 쓰면

표 79.4

반례. 잠금 안에서 되돌아 나오기

mtx_lock(&m);
if (bad) return -1;        /* ★ 잠긴 채로 나간다 --- 아무도 다시 못 들어온다 */
mtx_unlock(&m);

41장의 goto cleanup 무늬가 여기서 값을 한다. 나가는 길이 여럿이면 푸는 자리를 한 군데로 모은다. C 에는 다른 언어의 자동 해제 장치가 없으므로 이것은 사람이 지켜야 하는 규율이다 — 87장의 정리 무늬가 같은 문제를 다룬다.

79.5 기다리기 — cnd_*

잠금은 「한 번에 하나」를 준다. 그런데 자주 필요한 것은 다른 것이다 — 「생길 때까지 기다린다」. 그 자리가 조건 변수다.

예제의 둘째 묶음이 아주 작은 큐다. 소비자는 물건이 없으면 자고, 생산자가 넣으면서 깨운다.

mtx_lock(&qlock);
while (items == 0 && !closed)      /* ★ if 가 아니라 while */
    cnd_wait(&not_empty, &qlock);

while 인 것이 이 절의 핵심이다. cnd_wait 이 돌아왔다고 조건이 참이라는 보장이 없다. 이유가 둘이다.

  1. 다른 갈래가 먼저 가져갔을 수 있다. 깨어난 뒤 잠금을 다시 얻기까지 틈이 있다.
  2. 가짜 깨어남(spurious wakeup)이 표준적으로 허용된다 — 아무도 알리지 않았는데 깨어날 수 있다.

그래서 규칙은 하나다: 조건은 깨어난 뒤 다시 확인한다.

함수알아 둘 것
cnd_wait(&c, &m)잠금을 풀고 잔다. 깨어나면 잠금을 다시 얻은 뒤 돌아온다 — 부를 때 잠금을 쥐고 있어야 한다
cnd_timedwait(&c, &m, &ts)시한이 지나면 thrd_timedout. 시각은 절대 시각
cnd_signal(&c)기다리는 것 하나를 깨운다
cnd_broadcast(&c)기다리는 것 전부를 깨운다. 끝났음을 알릴 때
cnd_init·cnd_destroy잠금과 같다

표 79.5

79.6 한 번만 — call_once

게으른 초기화는 동시성에서 고전적인 함정이다. 「아직 안 만들었으면 만든다」를 소박하게 쓰면 둘이 동시에 「아직 안 만들었다」를 보고 둘 다 만든다.

static once_flag once = ONCE_FLAG_INIT;
call_once(&once, init_table);        /* 몇 갈래가 불러도 init_table 은 한 번 */

예제의 셋째 묶음이 그것을 확인한다 — 갈래 넷이 불렀는데 초기화는 한 번 돌았다. 넘길 함수는 인자도 반환도 없어야 한다(void (*)(void)).

79.7 갈래마다 따로 — thread_localtss_*

44장에서 저장 기간을 넷으로 갈랐고 그중 하나가 스레드 저장 기간이었다. 여기가 그것을 실제로 쓰는 자리다.

방법언제
thread_localstatic thread_local int mine;★ 대개 이쪽. 언어 기능이라 값싸고 읽기 쉽다(82장)
tss_*tss_create(&key, dtor) 로 열쇠를 만들고 tss_get·tss_set소멸자가 필요할 때 — 갈래가 끝날 때 정리할 것이 있으면

표 79.6

예제의 넷째 묶음에서 갈래 넷이 각각 10·20·30·40 을 보았고, main 의 몫은 0 그대로 였다. 이름은 하나인데 실체가 갈래 수만큼 있는 것이다.

tss_* 함수알아 둘 것
tss_create(&key, dtor)dtor 는 널일 수 있다. 갈래가 끝날 때 널 아닌 값에 대해 불린다
tss_get(key) / tss_set(key, p)값은 void * 하나뿐
tss_delete(key)열쇠를 버린다 — 남은 값의 소멸자는 불리지 않는다
TSS_DTOR_ITERATIONS소멸자가 다시 값을 넣으면 몇 번까지 되풀이하는가

표 79.7

79.8 ★ 그런데 왜 실무는 pthread 를 쓰는가

표준에 들어간 지 십수 년인데, 실제 C 코드는 여전히 pthread 나 Win32 를 쓴다. 이 장을 단순한 API 소개로 끝내지 않으려면 이 물음에 답해야 한다.

이유근거
① 표준이 「없어도 좋다」고 했다__STDC_NO_THREADS__. 있는지 확인하고 갈라 쓰는 부담을 라이브러리 저자에게 떠넘긴다
② 도착이 늦었다C11 은 2011년인데 glibc 는 2018년 2.28 에야 넣었다1 — 그 7년 사이에 사람들은 이미 pthread 로 다 짰다
③ ★ 있어야 할 곳에 없다macOS 는 <threads.h> 를 주지 않으면서 __STDC_NO_THREADS__ 도 정의하지 않는다 — 표준이 마련해 둔 탐지 방법이 그곳에서는 통하지 않는다
④ 기능이 얇다취소, 속성, 읽기-쓰기 잠금, 배리어, 우선순위 — pthread 에 있는 것들이 없다. 이미 쓰는 쪽에 옮겨 갈 이유가 없다

표 79.8

실제 사례. 이 기계에서는 잘 돈다 — 그리고 그것이 함정이다

이 책의 검증 환경(glibc 2.41)에서 __STDC_NO_THREADS__ 는 정의되어 있지 않고, 예제는 아무 특별한 옵션 없이 컴파일되고 돈다. thrd_t 는 8바이트, mtx_t 는 40바이트, cnd_t 는 48바이트, once_flag 는 4바이트였다.

★ 그런데 잘 도는 것이 이식성의 증거가 아니다. 위 표의 ③이 그 예다 — 같은 코드를 macOS 에서 빌드하면 헤더부터 없는데, 표준이 정해 둔 탐지 매크로가 없으니 #ifdef 로도 걸러지지 않는다. 「내 기계에서 되니까」가 가장 위험한 자리 가운데 하나다(12장의 회색지대).

문. 그러면 이 장은 왜 배우는가?

답. 세 가지 때문이다.

첫째, 이것이 표준이 정한 최소 어휘다. pthread 든 Win32 든 하는 일은 같다 — 만들기·기다리기·잠그기·기다렸다 깨어나기. 이 넷을 표준의 이름으로 익혀 두면 다른 API 는 같은 것의 다른 철자로 읽힌다. 실제로 대응이 거의 일대일이다.

C11POSIXWin32
thrd_createpthread_createCreateThread
thrd_joinpthread_joinWaitForSingleObject
mtx_lockpthread_mutex_lockEnterCriticalSection
cnd_waitpthread_cond_waitSleepConditionVariableCS
call_oncepthread_onceInitOnceExecuteOnce
thread_localpthread_key_*__declspec(thread)

표 79.9

둘째, 다음 장이 여기 기댄다. 원자적 연산은 갈래가 있어야 뜻이 있다.

셋째, ★ 이것이 78장의 부속서 K 와 같은 이야기의 두 번째 판이다. 표준에 들어갔다는 것과 세상이 쓴다는 것은 별개다. 무엇이 그 둘을 가르는가 — 도착 시점, 기존 자산, 그리고 「없어도 된다」는 단서. 표준을 읽을 때 조문만이 아니라 이 세 가지를 함께 보는 눈이 이 책이 기르려는 것이다.

79.9 쓸 자리와 쓰지 않을 자리

상황권고
혼자 쓰는 작은 도구, 이식 걱정 없음<threads.h> 로 충분하다 — 표준만으로 된다
리눅스·macOS·BSD 를 함께 겨냥pthread. macOS 사정이 결정적이다
윈도우까지얇은 껍질을 직접 쓰거나 이미 있는 것을 쓴다
갈래가 정말 필요한가부터★ 대개 아니다. 겹쳐 돌리는 다른 방법(코루틴·이벤트 루프)이 더 쌀 때가 많다(94장)
공유를 줄일 수 있다면줄인다. 공유하지 않는 것이 가장 싼 동기화다

표 79.10

복습 정리

  • 스레드 함수의 꼴은 int (*)(void *) 하나뿐이고, 반환값은 thrd_join 이 받는다.
  • 보호 없이 같은 칸을 만지면 실제로 값이 사라진다 — 이 책이 재서 확인했다. 표준의 낱말로는 데이터 경쟁이고, 그것은 미정의 동작이다.
  • mtx_* 는 「한 번에 하나」와 순서를 함께 준다. 나가는 길이 여럿이면 푸는 자리를 한 군데로 모은다.
  • cnd_wait 뒤의 조건 검사는 if 가 아니라 while 이다.
  • ★ 표준에 있다고 쓸 수 있는 것은 아니다 — macOS 는 헤더도 탐지 매크로도 주지 않는다. 이식이 필요하면 pthread 를 본다.

  1. glibc 2.28 NEWS: “Support for ISO C threads (ISO/IEC 9899:2011) has been added.” sourceware.org glibc NEWS