79 갈래를 나누어 돌리기 — <threads.h>
먼저 알아야 할 것
main의 세 얼굴 · 프로그램이 시작하고 끝나는 자리돌아보기
12장에서 클록을 더 올릴 수 없게 되자 코어를 여럿 두는 쪽으로 갔다고 했다. 그런데 지금까지 이 책의 모든 프로그램은 갈래가 하나였다. 코어가 여덟 개인 기계에서 그 프로그램은 무엇을 하고 있었는가?
답. 일곱 개를 놀리고 있었다. 그리고 그것이 잘못은 아니다 — 갈래를 늘리는 순간 치러야 할 값이 생기기 때문이다. 이 장이 그 값을 보여 준다. 갈래를 만드는 일 자체는 함수 두 개면 되는데, 같은 기억을 둘이 만지는 순간 이제까지 배운 「읽고 계산하고 쓴다」가 통째로 흔들린다. 그 흔들림을 눈으로 확인하는 것이 이 장의 절반이고, 나머지 절반은 잠금과 조건 변수로 그것을 다시 세우는 일이다.
이 장의 필요성과 맥락
이 장이 끝나면
mtx_*), 조건 변수(cnd_*), 한 번만 실행(call_once), 갈래마다 따로인 저장소까지 갖추면 표준이 정한 동시성 어휘가 완성된다. 마지막으로 왜 실무는 여전히 pthread 를 쓰는가를 짚는다 — 78장의 부속서 K 에 이은, 「표준에 들어갔다고 이기는 것은 아니다」의 두 번째 사례다.이 장에서 답할 질문
- 그런데 이 헤더는 없어도 되는 헤더라고 들었다.
- 그러면 이 장은 왜 배우는가?
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장). 반환한 int 는 thrd_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장). 각 갈래가 기억을 만지는 것은 사실상 한 번뿐이니 겹칠 일이 없었다.
그래서 예제의 shared 에 volatile 을 붙였다. ★ 고치려고 붙인 것이 아니다 — 매 반복을 진짜 읽기·쓰기로 만들어 경쟁이 일어나도록 붙인 것이다. 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(¬_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(¬_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(¬_empty); /* 잠금을 쥔 채 알린다 */
mtx_unlock(&qlock);
}
mtx_lock(&qlock);
closed = true;
cnd_broadcast(¬_empty); /* 끝났음을 모두에게 */
mtx_unlock(&qlock);
thrd_join(c, NULL);
printf(" produced 5, consumer took %d\n", taken);
cnd_destroy(¬_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_timed | mtx_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(¬_empty, &qlock);★ while 인 것이 이 절의 핵심이다. cnd_wait 이 돌아왔다고 조건이 참이라는 보장이 없다. 이유가 둘이다.
- 다른 갈래가 먼저 가져갔을 수 있다. 깨어난 뒤 잠금을 다시 얻기까지 틈이 있다.
- 가짜 깨어남(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_local 과 tss_*
44장에서 저장 기간을 넷으로 갈랐고 그중 하나가 스레드 저장 기간이었다. 여기가 그것을 실제로 쓰는 자리다.
| 방법 | 꼴 | 언제 |
|---|---|---|
thread_local | static 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 는 같은 것의 다른 철자로 읽힌다. 실제로 대응이 거의 일대일이다.
| C11 | POSIX | Win32 |
|---|---|---|
thrd_create | pthread_create | CreateThread |
thrd_join | pthread_join | WaitForSingleObject |
mtx_lock | pthread_mutex_lock | EnterCriticalSection |
cnd_wait | pthread_cond_wait | SleepConditionVariableCS |
call_once | pthread_once | InitOnceExecuteOnce |
thread_local | pthread_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를 본다.
주
- glibc 2.28 NEWS: “Support for ISO C threads (ISO/IEC 9899:2011) has been added.”
sourceware.orgglibc NEWS ↩