Proven C BookEnglish GitHub

94 경계 — 겹쳐 돌리기, 그리고 OS가 없을 때

먼저 알아야 할 것

80장 쪼개지지 않는 연산 · 쪼개지지 않는 연산
83장 프로그램의 기억 배치 · OS 가 없을 때

돌아보기

12장에서 클록의 한계 때문에 코어가 여럿이 됐고, 거짓 공유 같은 사고가 생긴다고 했다. 그러면 “동시에 여러 일을 한다”는 말에는 몇 가지 서로 다른 뜻이 있는가?

답. 적어도 둘이다. 겹쳐 돌리기(concurrency)는 여러 일이 서로 기다리며 번갈아 진행하는 것으로, 코어가 하나여도 성립한다 — 입출력을 기다리는 동안 다른 일을 하는 서버가 그렇다. 동시에 돌리기(parallelism)는 실제로 여러 코어가 같은 순간에 계산하는 것이다. 앞의 것은 구조의 문제이고 뒤의 것은 성능의 문제다. 이 장의 코루틴(coroutine)은 앞을, 작업 시스템은 뒤를 다룬다.

이 장의 필요성과 맥락

12부의 경계선을 긋는 장이다. 겹쳐 돌리기와 OS 없는 환경을 한 장에 묶은 것은 둘 다 「앞의 여섯 장이 세운 가정이 어디서 깨지는가」를 묻기 때문이다. 80장(원자적 연산)과 83장(기억 배치)이 여기서 마지막으로 쓰인다. 그리고 지원 범위를 정직하게 적어 두는 자리이기도 하다.

이 장이 끝나면

제12부의 마지막 장이다. 여러 일을 겹쳐 돌리는 두 가지 방법(스택리스 코루틴과 작업 시스템), 여러 갈래로 도는 프로그램에서 할당자와 포인터 출처가 왜 문제가 되는지, 그리고 운영체제가 아예 없는 자리에서 이 라이브러리가 어떤 모습이 되는지를 본다. 마지막 절은 언제 쓰지 않는 것이 나은가다.

이 장에서 답할 질문

  1. 그러면 이 부는 무엇을 위해 읽는 것인가?
  2. 그러면 이 부에서 배운 것 중 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바이트다. 별도 스택도, 스레드도, 할당도 없다. 매크로가 switchcase로 재진입 지점을 만드는, 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장에서 표준 라이브러리가 얇은 이유로 든 바로 그 환경 — 운영체제도, 힙도, 파일도 없는 자리 — 에서 돌아야 한다는 요구다.

프리스탠딩 빌드에서 달라지는 것은 이렇다.

앞 장들에서 “할당자를 인자로 받는다”, “뷰는 빌린 것이다”, “숨은 전역이 없다” 같은 규율이 왜 그렇게 집요했는지가 여기서 드러난다. 그 규율들은 취향이 아니라 이 환경에서 돌기 위한 최소 조건이었다.

94.4.1 실제 빌드 절차

말로만 두면 막연하므로 순서를 적어 둔다.

  1. platform/을 컴파일 목록에서 뺀다. src/proven/*.c만 남긴다. 파일·시간·OS 난수·스트림·mmap·작업 시스템이 함께 빠진다.
  2. PROVEN_FREESTANDING을 정의한다(-DPROVEN_FREESTANDING=1). proven_heap_allocator()쓸 수 없는 값(전부 0)을 돌려주게 되고, 그것을 실수로 쓰면 proven_alloc_is_valid가 거짓을 말한다.
  3. 기억의 바탕을 정적으로 잡는다. 링커 스크립트가 아는 자리에 배열 하나를 두고 그 위에 아레나를 얹는다(89장).
  4. 패닉 처리기를 등록한다. 콘솔이 없으므로 LED·워치독·재부팅 중 하나로 (87장의 예).
  5. 필요 없는 것을 뺀다. 실수 형식화를 쓰지 않으면 -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 계열의 스냅샷이고, 그 뜻은 이렇다.

94.5.3 아직 없는 것

정직하게 적어 둔다. 다음은 이 판에 없거나 얕다.

문. 그러면 이 부는 무엇을 위해 읽는 것인가?

답. 두 가지다. 하나는 설계를 읽는 법 — 어떤 라이브러리를 만나든 “실패를 어떻게 알리는가, 기억을 누가 대는가, 길이는 어디에 있는가, 경계를 넘으면 어떻게 되는가”를 묻는 눈이다. 이 눈은 proven 을 쓰지 않아도 남는다.

다른 하나는 선택의 근거다. 위의 표를 보고 “지금은 내 프로젝트에 이르다”고 판단했다면 그것도 이 부를 제대로 읽은 결과다. 머리말에서 앞의 이야기를 읽고 proven 이 필요 없다고 판단해도 좋은 결말이라고 한 것과 같은 뜻이다.

94.6 언제 쓰지 않는 것이 나은가

정직하게 닫는다. 이 라이브러리가 답이 아닌 경우가 있다.

문. 그러면 이 부에서 배운 것 중 proven을 쓰지 않아도 남는 것은 무엇인가?

답. 거의 전부다. 길이를 지니고 다니는 문자열, 자르지 않고 거부하는 쓰기, 값으로 돌아오는 에러, 시그니처에 드러나는 할당, 소유와 빌림의 구분, 검사 산술로 하는 크기 계산, 전순서를 지키는 비교자, 적대적 입력을 가정한 해시 — 이것들은 라이브러리가 아니라 설계 원칙이고, 어떤 C 코드에도 손으로 적용할 수 있다. 85장의 표를 다시 펴 보면 오른쪽 열이 전부 그런 항목이다. 이 부가 정말로 팔고 싶었던 것은 코드가 아니라 그 열이다.

복습 정리

제12부 전체 요약 — 문제와 답.

85장의 문제
크기를 모르는 문자열뷰(ptr+size), 거부하되 자르지 않기88·90장
확인되지 않는 실패값으로 오는 에러, [[nodiscard]]87장
서식 불일치{}PROVEN_ARG (_Generic)91장
불분명한 소유권할당자 매개변수, 소유 대 빌림89장
검사되지 않는 콜백문서화된 계약, introsort, 키드 해시92장
바이트의 숨은 타입proven_byte_t88장
OS가 없는 환경platform 층 분리, 정적 아레나94장

표 94.2

이것으로 이 책이 1장에서 밝힌 약속을 지켰다 — C의 문제들을 먼저 보이고, 그에 대한 하나의 대답을 끝까지 보였다. 남은 두 장은 지면 밖의 이야기다. 다음 장에서 실무의 지형(빌드 도구, 버전 관리, 실제 프로젝트들)을 둘러보고, 마지막 장에서 이 책이 지나온 길을 정리한다.