openlang

C 콜백에서는 설정해 둔 아레나가 사라진다

C 라이브러리가 내 Odin 코드로 콜백을 부르는데, 콜백은 등록 전에 설정해 둔 context를 보지 못한다. proc "c"에는 암묵적 context가 없어서 직접 만들어야 하고, runtime.default_context()가 주는 것은 기본 할당자다. 등록한 코드가 context.allocator에 넣어 둔 아레나는 오지 않는다. 콜백이 할당한 것은 전부 힙으로 가고 프레임 정리에 들어가지 않는다.

지금은 라이브러리의 user_data 포인터로 아레나를 넘기고 손으로 되돌려 놓는다.

import "base:runtime"
import "core:c"
import "core:mem"

on_event :: proc "c" (user: rawptr, code: c.int) {
	context = runtime.default_context()
	context.allocator = mem.arena_allocator((^mem.Arena)(user))
	handle_event(int(code))
}

동작은 한다. 아레나 포인터는 등록보다 오래 살아야 하는데 아무것도 그걸 확인하지 않는다. 콜백마다 저 두 줄이 반복된다. 더 깔끔한 패턴이 있나, 아니면 C로 넘어가는 대가가 원래 이 정도인가?

이어지는 대화 8

서로의 근거를 따라
  1. 고유 링크

    user_data 포인터는 라이브러리에 빌려준 것이고, 라이브러리가 그 뒤의 아레나를 소유하지는 않습니다. 라이브러리가 그 포인터를 얼마나 오래 쥐는지는 C 언어가 정하지 않으므로 헤더나 문서가 밝혀야 하며, 보통의 답은 짝이 되는 등록 해제 호출까지입니다. 따라서 아레나는 우연히 관찰한 마지막 콜백이 아니라 등록 해제 호출보다 오래 살아야 합니다.

    실제로는 등록 해제 함수를 찾아서, 호출 시점에 이미 실행 중인 콜백에 대해 무엇을 약속하는지 읽어 보시기 바랍니다. 문서에 아무 말이 없다면 기다리지 않고 반환한다고 가정해야 합니다.

  2. 고유 링크

    호출 규약 때문이다. 기본 규약의 Odin proc은 context를 숨은 추가 포인터 인자로 받고, C 호출자는 C 프로토타입에 적힌 것만 넘긴다. 그래서 default_context()는 원래 context를 되찾는 게 아니라 새로 만든다.

  3. 고유 링크

    할당자 문제보다 먼저 순서에 관한 질문이 하나 있습니다. 라이브러리가 콜백을 poll이나 dispatch 같은, 직접 부르는 함수 안에서만 호출합니까, 아니면 자체 스레드에서 호출합니까? 앞의 경우라면 콜백은 직접 고른 시점에 같은 스레드에서 실행되므로 아레나 방식으로 충분합니다. 뒤의 경우라면 아레나에 관한 가정이 전부 달라집니다.

  4. 고유 링크

    그렇다면 아레나 소유자가 건너뛸 수 없는 자리에 등록 해제를 둔다. 아레나를 초기화한 바로 뒤에 등록하고, 다음 줄에 defer unregister(handle)을 쓴다. Odin은 defer를 역순으로 실행하므로, 그 스코프를 빠져나가는 모든 경로에서 조기 반환까지 포함해 등록 해제가 아레나 정리보다 먼저 실행된다. 이것으로 순서는 고정된다. 등록 해제가 반환할 때 라이브러리 안에서 아직 실행 중인 콜백에 대해서는 아무것도 하지 못한다. nullbyte가 확인하라고 한 바로 그 경우다.

  5. 고유 링크

    자체 스레드다. 헤더에 이에 관한 문장이 하나 있는데, 콜백은 내부 워커에서 실행된다고 한다. 그러니 아레나 방식은 우연히 드러나지 않았을 뿐 틀린 것이었다. mem.Arena는 동시 사용에 안전하지 않고, 콜백이 도는 동안 메인 루프도 같은 아레나에서 할당한다.

    바꿨다. 이제 콜백은 아무것도 할당하지 않는다. 뮤텍스로 보호하는 고정 크기 링에 이벤트 코드만 넣고, 프레임 루프가 매 프레임 시작에 메인 스레드에서 링을 비운다. 거기서는 일반 context와 아레나가 둘 다 유효하다. 첫 글의 그 두 줄은 없어졌다. 링 크기가 고정이라 남은 문제는 링이 찼을 때 콜백이 무엇을 하느냐로 옮겨 갔다.

  6. 고유 링크

    오, 제가 제일 믿는 모양이에요. 워커 스레드는 작은 사실 하나만 넘기고, 소유한 스레드가 자기가 고른 시점에 진짜 일을 해요. Tcl도 인터프리터에 대해 같은 규칙에 도착했어요. 다른 스레드는 Tcl_ThreadQueueEvent로 이벤트를 올리고, 인터프리터의 자기 스레드가 나중에 실행해요.

    링이 찼을 때는 가장 새 이벤트를 버리고 버린 개수를 세는 게 순한 선택이에요. 프레임 루프가 그 개수를 로그에 남기면 되고요. 프레임이 비워 줄 때까지 라이브러리 워커를 막는 건 무서운 쪽이에요. 라이브러리가 콜백을 부르는 동안 자기 락을 쥐고 있을 수도 있으니까요.

  7. 고유 링크

    링 덕분에 콜백에서 아레나는 빠졌고, 제 첫 답글의 수명 문제는 이제 링과 그 뮤텍스에 적용됩니다. 둘 다 user_data를 통해 접근하므로, 실행 중일 수 있는 콜백이 하나도 남지 않을 때까지 유효해야 합니다. 그 시점이 등록 해제 호출의 반환인지가 헤더가 알려 줘야 하는 단 하나의 사항입니다. 아무 말이 없다면 라이브러리 자체를 종료하기 전에는 링을 안전하게 해제할 수 없습니다.

  8. 고유 링크

    "실행 중일 수 있는 콜백이 하나도 없다"는 말 안에 따로 떨어진 이정표가 두 개 숨어 있어요. 하나는 등록 해제가 반환해서 라이브러리가 새 호출을 더 시작하지 않는다는 것이고, 다른 하나는 이미 시작된 호출이 전부 끝났다는 거예요. 콜백 안의 카운터만으로는 두 번째를 확인할 수 없어요. 호출이 라이브러리의 디스패치 코드 안에서 이미 콜백을 부르기로 정해 놓고, 카운터를 올리는 첫 줄에는 아직 도착하지 않았을 수 있거든요.

    그래서 두 번째 이정표는 라이브러리가 줘야 해요. 기다려 주는 등록 해제든, 워커를 비우는 종료 함수든요. 둘 다 없다면 이 정도 크기의 링은 프로세스가 끝날 때까지 살려 두는 것도 충분히 괜찮은 답이에요.

같은 질문의 다른 갈래

  1. C 포인터에 .?를 쓰는 것은 단언이다

    C에서 돌아온 포인터에 쓰는 .?는 검사가 아니라 단언입니다. 여기서 널은 불가능하다고 말하는 것입니다. 안전 검사 빌드에서 널이면 패닉이 나고, ReleaseFast나 ReleaseSmall에서는 정의되지 않은 동작입니다.…

    답글 5zig
  2. 상태 플래그 하나를 |=로 지웠더니 전부 지워졌다

    쓰기 1로 지우는 상태 레지스터에 |=를 쓰면 이름 붙인 비트보다 더 많이 지워진다. 오버런 플래그는 1을 써야 지워진다. 0을 쓰면 아무 일도 없다. UART_SR |= SR_ORE는 먼저 레지스터 전체를 읽는다. 그 순간…

    답글 5c
  3. -O2 리스트에서 카나리 검사 읽기

    작은 C 함수 세 개를 x86-64 Linux에서 gcc -O2 -fstack-protector-strong -S -masm=intel로 빌드했다. read_line과 read_count는 같은 프롤로그를 받는다. mov r…