94 경계 — 겹쳐 돌리기, 그리고 OS가 없을 때
먼저 알아야 할 것
돌아보기
12장에서 클록의 한계 때문에 코어가 여럿이 됐고, 거짓 공유 같은 사고가 생긴다고 했다. 그러면 “동시에 여러 일을 한다”는 말에는 몇 가지 서로 다른 뜻이 있는가?
답. 적어도 둘이다. 겹쳐 돌리기(concurrency)는 여러 일이 서로 기다리며 번갈아 진행하는 것으로, 코어가 하나여도 성립한다 — 입출력을 기다리는 동안 다른 일을 하는 서버가 그렇다. 동시에 돌리기(parallelism)는 실제로 여러 코어가 같은 순간에 계산하는 것이다. 앞의 것은 구조의 문제이고 뒤의 것은 성능의 문제다. 이 장의 코루틴(coroutine)은 앞을, 작업 시스템은 뒤를 다룬다.
이 장의 필요성과 맥락
이 장이 끝나면
이 장에서 답할 질문
- 그러면 이 부는 무엇을 위해 읽는 것인가?
- 그러면 이 부에서 배운 것 중 proven을 쓰지 않아도 남는 것은 무엇인가?
94.1 스택리스 코루틴 — 스레드 없이 겹쳐 돌리기
한 함수가 일을 하다 멈추고, 나중에 그 자리에서 다시 시작할 수 있으면 많은 것이 쉬워진다. 상태 기계를 손으로 짜는 대신 코드를 순서대로 쓸 수 있기 때문이다.
examples/ch94/coro.c
#include <proven.h>
#include <stdio.h>
/* 스택리스 코루틴: 진행 상태를 구조체 한 칸에 담고, 매크로가 switch 로
그 자리에 되돌아간다. 스레드도, 별도 스택도, 할당도 없다. */
typedef struct {
proven_coro_t co; /* 어디까지 갔는지 기억하는 칸 */
int sent;
int limit;
} producer_t;
/* 0 을 돌려주면 "값 하나를 내놓고 잠시 멈춤", 1 이면 "다 끝남" */
static int produce(producer_t *p, int *out)
{
PROVEN_CORO_BEGIN(&p->co);
while (p->sent < p->limit) {
*out = p->sent * 10;
p->sent += 1;
PROVEN_CORO_YIELD(&p->co); /* 여기서 나갔다가 다음 호출에 되돌아온다 */
}
PROVEN_CORO_END(&p->co);
}
int main(void)
{
producer_t p = { .sent = 0, .limit = 4 };
PROVEN_CORO_INIT(&p.co);
int value = -1;
while (!PROVEN_CORO_IS_DONE(&p.co)) {
if (produce(&p, &value) == 0)
printf("produced %d\n", value);
}
printf("finished after %d values; coroutine state is %zu bytes\n",
p.sent, sizeof(proven_coro_t));
return 0;
}
실행 결과
produced 0
produced 10
produced 20
produced 30
finished after 4 values; coroutine state is 4 bytes
핵심은 마지막 줄이다 — 이 코루틴이 기억하는 상태는 4바이트다. 별도 스택도, 스레드도, 할당도 없다. 매크로가 switch와 case로 재진입 지점을 만드는, C에서 오래된 요령(Duff’s device의 사촌이다 — 32장에서 본 그 문법이 여기 다시 쓰인다)을 정리한 것이다.
대가도 분명하다. 지역 변수가 살아남지 않는다 — 멈췄다 돌아오면 사라지고 없으므로, 유지해야 할 상태는 구조체에 두어야 한다. 예제가 sent를 구조체 멤버로 둔 이유다. 그리고 switch로 구현되었으므로 PROVEN_CORO_YIELD를 다른 switch 안에 넣을 수 없다.
그래도 이 모델이 임베디드에서 사랑받는 이유는 뚜렷하다. 태스크 하나가 4바이트라면 수백 개를 띄워도 부담이 없고, 스택을 따로 잡지 않으니 기억 사용량을 컴파일 시간에 계산할 수 있다.
흔한 오해. “코루틴은 스레드의 가벼운 버전이다”
하는 일이 아예 다르다. 스레드는 운영체제가 마음대로 끼어들어 전환 하지만(선점), 코루틴은 오직 자기가YIELD라고 적은 자리에서만 멈춘다 (협력). 그래서 코루틴들 사이에는 경쟁 조건이 생기지 않는다 — 두 코루틴이 같은 순간에 도는 일이 없기 때문이다. 대신 대가가 있다. 한 코루틴이 오래 계산하며 양보하지 않으면 나머지가 전부 굶는다. 그리고 코루틴은 코어를 여럿 쓰지 못한다 — 그것이 다음 절의 작업 시스템이 따로 있는 이유다.94.2 작업 시스템 — 코어를 여럿 쓰기
proven_job_sys_t는 일감 큐와 작업자 스레드를 갖춘 작은 시스템이다. 큐의 크기가 고정이라는 점이 특징이다 — 넘치면 제출이 실패하고, 그 실패는 값으로 온다. 무한히 늘어나는 큐는 기억을 다 쓸 때까지 문제를 미루는 장치일 뿐이라는 판단이다.
문은 다섯이고, 순서가 곧 계약이다.
proven_job_sys_t *sys;
proven_err_t e = proven_job_system_init(alloc, 4, 256, &sys); /* ① 작업자 4, 큐 256 */
bool ok = proven_job_submit(sys, routine, arg); /* ② 제출 (가득 차면 false) */
bool did = proven_job_execute_one(sys); /* ③ 이 스레드도 하나 처리 */
proven_job_system_close(sys); /* ④ 더 받지 않는다 */
proven_job_system_destroy(sys); /* ⑤ 작업자를 합류시키고 정리 */불투명 타입이라는 점이 눈에 띈다 — proven_job_sys_t의 속은 헤더에 없고, 포인터로만 다룬다(60장에서 말한 불투명 타입의 실물이다). 스레드와 잠금 같은 플랫폼 자원이 안에 있어서, 그 배치를 사용자 코드에 노출하지 않으려는 것이다.
④와 ⑤가 나뉘어 있는 이유도 계약이다. 닫기는 “더 이상 제출을 받지 않는다”이고, 파괴는 “작업자가 다 끝나기를 기다린 뒤 정리한다”다. 그 사이에 생산자 스레드들을 합류시키라는 것이 헤더의 요구다.
proven_job_execute_one은 조금 특별하다. 제출한 쪽도 일감 하나를 직접 처리하게 해 준다 — 큐가 가득 차서 제출이 실패했을 때 그냥 기다리는 대신 자기가 하나 처리하고 다시 시도하는 무늬(작업 훔치기의 단순한 형태)를 만들 수 있다.
반례. 닫기와 제출을 겹치기
/* 스레드 A */ /* 스레드 B */
proven_job_submit(sys, job); proven_job_system_destroy(sys);헤더가 명시적으로 금지하는 무늬다. 올바른 순서는 닫고(close), 생산자를 모두 합류시킨 뒤, 파괴(destroy)다. 이런 순서 계약은 자료구조 안쪽에 락을 숨겨서는 해결되지 않는다 — 오히려 숨겨진 락이 있으면 “된다고 착각하는” 코드가 늘어난다. 이 라이브러리가 컨테이너에 락을 넣지 않고 공유 변경은 호출자가 동기화한다고 못박은 이유이기도 하다.
94.3 스레드, 할당자, 그리고 출처
여러 갈래로 도는 프로그램에서 할당자는 특별한 주의를 요구한다. 아레나 하나를 두 스레드가 함께 쓰면, 89장에서 본 “앞에서부터 잘라 주는” 그 단순한 동작이 곧바로 경쟁이 된다. 처방은 대개 스레드마다 하나씩 두는 것이다 — 그러면 동기화가 아예 필요 없다.
14장에서 이름만 보았던 포인터 출처(provenance)도 여기서 실무가 된다. 같은 주소라도 어느 할당에서 온 포인터인지가 의미를 가지므로, 어떤 할당자에서 얻은 블록을 다른 할당자에게 돌려주는 것은 (89장의 반례처럼) 주소가 우연히 맞더라도 계약 위반이다. 라이브러리의 이름이 여기서 왔다.
94.4 OS가 없을 때 — 프리스탠딩
이 라이브러리의 모양을 가장 크게 결정한 제약이 이것이다. 61장에서 표준 라이브러리가 얇은 이유로 든 바로 그 환경 — 운영체제도, 힙도, 파일도 없는 자리 — 에서 돌아야 한다는 요구다.
프리스탠딩 빌드에서 달라지는 것은 이렇다.
platform/을 아예 넣지 않는다. 파일·시간·OS 난수가 사라진다.- 할당은 정적 버퍼 위의 아레나로 한다(89장).
malloc은 없다. - 문자열은
_borrow로 스택·정적 버퍼 위에서 다룬다(90장). - 패닉 처리기를 직접 등록한다 — 인쇄할 콘솔이 없을 수 있으므로, LED를 켜거나 워치독을 물리거나 재부팅하는 식이 된다.
- 실수 형식화는 큰 정수 연산을 쓰므로, 필요 없으면 통째로 뺄 수 있다.
앞 장들에서 “할당자를 인자로 받는다”, “뷰는 빌린 것이다”, “숨은 전역이 없다” 같은 규율이 왜 그렇게 집요했는지가 여기서 드러난다. 그 규율들은 취향이 아니라 이 환경에서 돌기 위한 최소 조건이었다.
94.4.1 실제 빌드 절차
말로만 두면 막연하므로 순서를 적어 둔다.
platform/을 컴파일 목록에서 뺀다.src/proven/*.c만 남긴다. 파일·시간·OS 난수·스트림·mmap·작업 시스템이 함께 빠진다.PROVEN_FREESTANDING을 정의한다(-DPROVEN_FREESTANDING=1).proven_heap_allocator()가 쓸 수 없는 값(전부 0)을 돌려주게 되고, 그것을 실수로 쓰면proven_alloc_is_valid가 거짓을 말한다.- 기억의 바탕을 정적으로 잡는다. 링커 스크립트가 아는 자리에 배열 하나를 두고 그 위에 아레나를 얹는다(89장).
- 패닉 처리기를 등록한다. 콘솔이 없으므로 LED·워치독·재부팅 중 하나로 (87장의 예).
- 필요 없는 것을 뺀다. 실수 형식화를 쓰지 않으면
-DPROVEN_FMT_NO_FLOAT로 큰 정수 연산 코드를 통째로 걷어낼 수 있다.
/* 프리스탠딩 프로그램의 뼈대 */
static proven_byte_t g_pool[8 * 1024]; /* 기억 예산은 여기서 정해진다 */
int main(void)
{
proven_set_panic_handler(board_panic);
proven_arena_t arena = proven_arena_create(
(proven_mem_mut_t){ .ptr = g_pool, .size = sizeof g_pool });
proven_allocator_t alloc = proven_arena_as_allocator(&arena);
for (;;) {
proven_arena_reset(&arena); /* 매 회전마다 되돌린다 */
handle_one_event(alloc);
}
}이 스무 줄에 이 부의 규율이 전부 들어 있다 — 할당자는 인자, 수명은 아레나 단위, main은 돌아오지 않는다(53장), 그리고 기억 예산이 소스에 숫자로 적혀 있다.
실제 사례. 같은 코드를 두 세계에서 — 그리고 그 대가
임베디드와 호스트 양쪽에서 도는 코드를 한 벌로 유지하는 것은 실제로 가치가 크다. 프로토콜 파서를 PC에서 테스트하고 그대로 펌웨어에 넣을 수 있다면, 디버깅 환경이 없는 보드 위에서 헤매는 시간이 크게 줄기 때문이다. 많은 임베디드 팀이 “호스트에서 도는 시험용 빌드”를 별도로 유지하는 이유이고, 그 구조를 가능하게 하는 것이 정확히 플랫폼 의존을 한 층에 가두는 설계다. 대가는 API가 조금 더 격식을 갖추게 된다는 것 — 어디서나 통하는 코드는 어디에도 특화되지 않는다.94.5 이 라이브러리의 자리 — 무엇이고, 무엇이 아닌가
이 부를 닫기 전에 자리를 분명히 해 두어야 한다. 지금까지 본 설계 — 할당자를 인자로 받기, 실패를 값으로 돌려주기, 길이를 지닌 뷰, 자르지 않고 거부하기 — 는 proven 이 발명한 것이 아니다. Zig 의 할당자 인자, Rust 의 Result 와 슬라이스, 최근 C++ 의 span·expected, 그리고 여러 회사의 사내 C 규약이 같은 방향을 향해 왔다. 이 방향에는 상당한 공감대가 있다.
그러나 proven 이 그 공감대 자체는 아니다. proven 은 그 방향을 C23 로 구현해 보려는 하나의 시도이고, 표준도 업계 표준 부품도 아니다. 이 구별이 중요한 이유는 간단하다 — 방향은 여러 곳에서 검증됐지만, 이 구현은 아직 그렇지 않기 때문이다. 이 부를 읽고 가져갈 것은 코드보다 방향이고, 코드를 고를 때는 아래의 사정을 알고 골라야 한다.
94.5.1 지금 무엇이 검증되었는가
이 책이 지면에 인쇄한 것은 정확히 다음까지다.
| 확인된 것 | 확인되지 않은 것 |
|---|---|
| 이 책의 94개 예제가 GCC 14 와 Clang 22 로 빌드·실행된다 | 다른 컴파일러(MSVC·구형 GCC)에서의 동작 |
| x86-64 리눅스에서 전수 통과한다 | 다른 운영체제·아키텍처의 상시 검증 |
| 예제가 쓰는 API 의 계약이 문서와 일치한다 | API 전체의 커버리지 |
| 플랫폼 층에 POSIX 와 Win32 두 갈래 구현이 있다 | Win32 갈래의 상시 자동 검증 |
표 94.1
즉 이 책이 보증하는 것은 여기 실린 코드가 이 환경에서 돈다는 사실이지, 라이브러리 전체가 모든 환경에서 검증됐다는 뜻이 아니다. 이 책은 그 이상을 주장하지 않는다.
94.5.2 안정성 — 지금은 무엇이 바뀔 수 있는가
proven 은 아직 1.0 이 아니다. 이 책이 쓴 판은 v26.07.23b 계열의 스냅샷이고, 그 뜻은 이렇다.
- API 는 바뀔 수 있다. 이름과 시그니처가 정리되는 중이다. 이 책의 예제가 다음 판에서 그대로 컴파일된다는 보장은 없다.
- ABI 는 약속되지 않았다. 구조체의 배치가 바뀔 수 있으므로, 미리 컴파일된 바이너리를 섞어 링크하는 사용법은 지금 단계에서 권하지 않는다. 소스와 함께 빌드하는 쪽이 안전하다.
- 판을 고정해 쓰라. 실무에 쓴다면 특정 스냅샷을 저장소에 함께 넣고 (이 책이
vendor/proven으로 그렇게 한다) 의식적으로 올리는 편이 낫다.
94.5.3 아직 없는 것
정직하게 적어 둔다. 다음은 이 판에 없거나 얕다.
- 성능·크기 비교 자료. 표준 라이브러리나 다른 C 라이브러리와의 벤치마크가 공개돼 있지 않다. 이 책이 성능을 주장하지 않는 이유다 — 자료 없이 하는 성능 주장은 광고다.
- 외부 사용 사례. 저자 밖의 프로젝트에서 쓰인 기록이 아직 적다.
- 넓은 플랫폼 매트릭스. 임베디드(프리스탠딩) 갈래는 설계돼 있으나 상시 검증되는 대상 목록이 좁다.
- 긴 호환성 이력. 오래된 라이브러리가 주는 “이미 여러 판을 넘겨 봤다”는 신뢰는 시간으로만 쌓인다.
문. 그러면 이 부는 무엇을 위해 읽는 것인가?
답. 두 가지다. 하나는 설계를 읽는 법 — 어떤 라이브러리를 만나든 “실패를 어떻게 알리는가, 기억을 누가 대는가, 길이는 어디에 있는가, 경계를 넘으면 어떻게 되는가”를 묻는 눈이다. 이 눈은 proven 을 쓰지 않아도 남는다.
다른 하나는 선택의 근거다. 위의 표를 보고 “지금은 내 프로젝트에 이르다”고 판단했다면 그것도 이 부를 제대로 읽은 결과다. 머리말에서 앞의 이야기를 읽고 proven 이 필요 없다고 판단해도 좋은 결말이라고 한 것과 같은 뜻이다.
94.6 언제 쓰지 않는 것이 나은가
정직하게 닫는다. 이 라이브러리가 답이 아닌 경우가 있다.
- 짧은 스크립트성 프로그램 — 스무 줄짜리 도구에 소유권 규율과 할당자 매개변수는 격식일 뿐이다. 표준 라이브러리로 충분하다.
- 이미 다른 규약을 가진 코드베이스 — 큰 프로젝트에는 대개 자체 문자열· 컨테이너·에러 규약이 이미 있다. 두 규약이 섞이면 경계마다 변환 코드가 생기고, 그 경계가 새 버그의 자리가 된다.
- C++·Rust처럼 이미 그 문제를 언어가 푸는 곳 — 소유권과 에러 전파를 언어가 다루는 환경에서 굳이 C 라이브러리를 얹을 이유는 적다.
- 표준 함수 하나면 끝나는 일 — 이 책이 61장에서 말한 그대로다. 옳은 태도는 “쓰지 않기”도 “무조건 쓰기”도 아니라 계약을 알고 고르기다.
문. 그러면 이 부에서 배운 것 중 proven을 쓰지 않아도 남는 것은 무엇인가?
답. 거의 전부다. 길이를 지니고 다니는 문자열, 자르지 않고 거부하는 쓰기, 값으로 돌아오는 에러, 시그니처에 드러나는 할당, 소유와 빌림의 구분, 검사 산술로 하는 크기 계산, 전순서를 지키는 비교자, 적대적 입력을 가정한 해시 — 이것들은 라이브러리가 아니라 설계 원칙이고, 어떤 C 코드에도 손으로 적용할 수 있다. 85장의 표를 다시 펴 보면 오른쪽 열이 전부 그런 항목이다. 이 부가 정말로 팔고 싶었던 것은 코드가 아니라 그 열이다.
복습 정리
제12부 전체 요약 — 문제와 답.
| 85장의 문제 | 답 | 장 |
|---|---|---|
| 크기를 모르는 문자열 | 뷰(ptr+size), 거부하되 자르지 않기 | 88·90장 |
| 확인되지 않는 실패 | 값으로 오는 에러, [[nodiscard]] | 87장 |
| 서식 불일치 | {} 와 PROVEN_ARG (_Generic) | 91장 |
| 불분명한 소유권 | 할당자 매개변수, 소유 대 빌림 | 89장 |
| 검사되지 않는 콜백 | 문서화된 계약, introsort, 키드 해시 | 92장 |
| 바이트의 숨은 타입 | proven_byte_t | 88장 |
| OS가 없는 환경 | platform 층 분리, 정적 아레나 | 94장 |
표 94.2
이것으로 이 책이 1장에서 밝힌 약속을 지켰다 — C의 문제들을 먼저 보이고, 그에 대한 하나의 대답을 끝까지 보였다. 남은 두 장은 지면 밖의 이야기다. 다음 장에서 실무의 지형(빌드 도구, 버전 관리, 실제 프로젝트들)을 둘러보고, 마지막 장에서 이 책이 지나온 길을 정리한다.