sched -- 실시간 I/O 스케줄러 (리액터)
std/sched/는 std::TaskScheduler를
구동하는 단일 스레드 I/O 준비 상태 이벤트 루프 — 즉 리액터(reactor) —
를 추가하여, 여러 개의 동시적인 네트워크·파일·시그널 작업이 단 하나의
실제 OS 스레드 위에서, 그중 어느 하나 때문에 프로그램 전체가 블록되는
일 없이 진행될 수 있게 합니다. 이는 epoll/kqueue 기반의 다른 런타임들과
같은 의미에서의 "비동기"이며, 경쟁하는 별도의 동시성 기본 요소를
새로 도입하는 대신 SafeC에 이미 있는 협력형 스케줄러 위에 직접
구축되었습니다.
이 모듈은 실제 syscall을 통해 실제 OS 파일 디스크립터에서 동작하는
호스트 전용 모듈입니다 — OS 소켓 syscall을 전혀 사용하지 않는,
처음부터 작성된 베어메탈 지향 패킷 수준 TCP/IP 스택인
std/net/tcp.sc와는 의도적으로 분리되어 있습니다. 이
둘은 서로 다른 대상(호스트 유저스페이스 대 OS가 없는 임베디드)을
다루며, 어느 쪽도 다른 쪽의 대체물이 아닙니다.
#include <std/sched/reactor.h>
#include <std/sched/io_nb.h> // non-blocking socket/file helpers meant to pair with it
백엔드
reactor.h는 하나의 이식 가능한 struct Reactor/API를 선언합니다.
대상에 맞는 .sc 파일을 골라 직접 include합니다(safeguard의 표준
라이브러리 빌드는 현재 대상에서 컴파일/어셈블이 되지 않는 나머지 두
파일을 허용하고 건너뜁니다 — 표준 라이브러리 결정 방식
참고):
| 파일 | 대상 | 메커니즘 | SCHED_SIGNAL |
|---|---|---|---|
reactor_kqueue.sc |
macOS, iOS, FreeBSD | kqueue/kevent, 영구 등록 | 네이티브 (EVFILT_SIGNAL) |
reactor_epoll.sc |
Linux, Android | epoll, 영구 등록 | signalfd()를 통해 |
reactor_win32.sc |
Windows | WSAPoll, 폴링마다 재등록(영구적인 OS 측 집합 없음) |
지원되지 않음 — add()가 조용히 아무 동작도 하지 않음; 이유와 대안(SetConsoleCtrlHandler를 다른 방식으로 스케줄러에 신호를 보내도록 연결)은 해당 파일 자체의 헤더 주석 참고 |
세 백엔드 모두 정확히 동일한 표면을 구현하므로, 호출하는 코드는 어느
것이 컴파일되어 들어갔는지 알 필요가 없습니다 — SCHED_READ/
SCHED_WRITE/SCHED_SIGNAL과 TaskScheduler::await_fd/unblock_fd는
이미 백엔드에 구애받지 않습니다. reactor_run 자체(아래 참고)는 백엔드마다
다시 구현되는 것이 아니라 세 백엔드 모두에서 공유됩니다(reactor.sc).
검증 상태
reactor_kqueue.sc는 macOS에서 종단 간(컴파일, 링크, 실행)으로
검증되었고, reactor_epoll.sc는 Linux에서 std 테스트 스위트가 루프백
비동기 TCP와 그 위의 HTTP 서버를 돌려 검증합니다. reactor_win32.sc는
컴파일되고 크로스 타겟 검증(--target을 통한 실제 오브젝트 코드 생성,
그리고 플랫폼 ABI에 민감한 부분에 대한 static_assert 검사 —
EpollEvent의 패킹된 12바이트 레이아웃, WSAPollFd의 16바이트
레이아웃)까지 되었습니다.
사용 형태
- 평소처럼
std::TaskScheduler에 태스크를 spawn합니다(동기화 참고) — 각 태스크는 동일한int(?&T arg, int resume_point)yield 프로토콜을 따릅니다. - 원래라면 I/O에서 블록되었을 태스크는, 자신의 차례 안에서 실제로
블록되는 대신 자기 자신에 대해
sched->await_fd(fd, SCHED_READ)(또는SCHED_WRITE/SCHED_SIGNAL)를 호출합니다. TaskScheduler::run_all()대신std::reactor_run(sched, reactor)로 전체를 구동합니다.
Reactor
#define SCHED_READ 1
#define SCHED_WRITE 2
#define SCHED_SIGNAL 3 // 이 필터에서는 'fd'가 실제로는 시그널 번호다
struct Reactor {
int kq; // 내부 OS 이벤트 큐 fd (kqueue fd, epoll fd; Win32 백엔드에서는 미사용)
// 백엔드별 내부 관리용 — 필요 없는 백엔드에서는 비워진 채 사용되지
// 않는다(각각이 왜 존재하는지는 reactor.h 참고):
struct Vec sigfds; // reactor_epoll.sc의 signum -> signalfd 테이블
struct Vec watched; // reactor_win32.sc의 등록된 (fd, filter) 테이블
static struct Reactor new(); // OS 큐는 아직 없음(kq = -1); 이어서 init()을 호출합니다
int init(); // 백엔드를 열고/초기화; 성공 0, 실패 -1
void add(int fd, int filter); // 영구적인, 레벨 트리거 관심 등록
void remove(int fd, int filter);
int poll(&TaskScheduler sched, long long timeout_ms);
// timeout_ms: 0 = 대기 중인 것이 없으면 즉시 반환, 음수 = 무기한 블록.
// 준비된 (fd, filter)마다 sched->unblock_fd()를 호출한다. 처리된
// OS 이벤트 수를 반환한다(여러 태스크가 같은 fd를 기다린다면
// 언블록된 태스크 수와 다를 수 있다).
// poll()처럼 기다리지만, 준비된 (fd, filter)를 TaskScheduler 대신
// on_ready(ctx, fd, filter)에 넘긴다: 다른 스케줄러(std/async의
// IoDriver)가 리액터를 구동하는 방법. poll()은 sched->unblock_fd()를
// 호출하는 콜백을 넘긴 이것이다. 각 백엔드는 이것만 구현한다.
generic<T> int poll_events(fn void(?&T, int, int) on_ready, ?&T ctx, long long timeout_ms);
void close_(); // OS 큐(epoll에서는 연 signalfd들, Windows에서는 WSAStartup)를 닫고
// 등록 테이블을 해제한다. 멱등: 닫힌 reactor는 Reactor::new()와 같아서
// init()으로 다시 열 수 있고, 다시 닫거나 drop해도 아무 일도 없다
void drop(); // close_() — Reactor는 Drop 타입이므로 지역 변수는 모든 경로에서 drop한다
}
std/async의 IoDriver는 poll_events 위에 만들어집니다: 준비된 fd를 기다리는 퓨처들을 깨우고, 비동기 실행기가 기다리는 수단이 됩니다.
reactor_run — 스케줄러 그 자체
void reactor_run(&TaskScheduler sched, &Reactor r);
sched를 완료까지 구동합니다: 실행 가능한 모든 태스크를 틱하고, 즉시
실행 가능한 태스크가 없을 때마다 리액터를 폴링합니다 — 일부 태스크가
단지 라운드 중간이면(새로 준비된 I/O를 지연 없이 집어내도록) 타임아웃을
0으로, 남은 것이 전부 I/O에서 블록되어 있다면 무기한 대기로 폴링하여,
OS가 실제로 보고할 것이 생기기 전까지 프로세스가 CPU를 전혀 소비하지
않도록 합니다. 태스크가 await_fd()를 호출하는 모든 워크로드에서
TaskScheduler::run_all() 대신 이것을 사용합니다.
예제
#include <std/sched/reactor.h>
#include <std/sched/io_nb.h>
#include <std/io.h>
struct Conn { int fd; };
static struct TaskScheduler g_sched; // tasks reach the scheduler through a static
static int echo_conn(?&Conn c, int resume_point) {
match (c) {
case null: return 0;
case some(conn): {
if (resume_point == 0) {
g_sched.await_fd(conn.fd, SCHED_READ); // park until readable
return 1;
}
// ... read/write conn.fd here ...
}
}
return 0; // done
}
int main() {
g_sched = std::TaskScheduler::new();
struct Reactor reactor = std::Reactor::new();
reactor.init();
int listenfd = std::tcp_listen_nb(8080);
reactor.add(listenfd, SCHED_READ);
// ... an accept task spawns echo_conn per connection:
// g_sched.spawn(echo_conn, conn_ref);
std::reactor_run(&g_sched, &reactor);
reactor.drop();
return 0;
}
논블로킹 I/O 헬퍼 (io_nb.h)
open/socket/accept/connect를 감싸 논블로킹 모드를 활성화하는
얇은 래퍼로, 위의 리액터와 짝을 이루도록 만들어졌습니다: 태스크가 이
함수들 중 하나를 호출하면 프로그램 전체를 블로킹하는 대신 곧바로
EAGAIN/EWOULDBLOCK을 받고, 대신 await_fd를 통해 준비 상태를
기다립니다. 리액터와 마찬가지로 io_nb.h는 하나의 이식 가능한 API를
플랫폼별 백엔드와 함께 선언합니다:
| 파일 | 대상 | 논블로킹 메커니즘 |
|---|---|---|
io_nb_bsd.sc |
macOS, iOS, FreeBSD | fcntl + O_NONBLOCK (0x0004) |
io_nb_linux.sc |
Linux, Android | fcntl + O_NONBLOCK (0x800) |
io_nb_win32.sc |
Windows | 소켓: ioctlsocket + FIONBIO. 파일: 최선 노력(best-effort) — 아래 참고 |
#define SCHED_AF_INET 2 // 모든 백엔드에서 동일한 값 (BSD 소켓에서
#define SCHED_SOCK_STREAM 1 // 비롯된 우연으로, 이후의 모든 API가 — Winsock을
#define SCHED_O_RDONLY 0 // 포함해 — 의도적으로 유지)
#define SCHED_O_WRONLY 1
#define SCHED_O_RDWR 2
unsigned short sched_htons(unsigned short host16);
unsigned int sched_htonl(unsigned int host32);
unsigned int sched_ipv4(unsigned char a, unsigned char b, unsigned char c, unsigned char d);
int fd_set_nonblocking(int fd); // 열린 fd에 대해 논블로킹 모드 활성화
int fd_set_blocking(int fd); // 블로킹 모드로 복귀 (연결 진행 중에도 안전)
int fd_open_nb(const &char path, int flags, int mode); // 지원되는 곳에서 논블로킹 모드로 open()
int tcp_listen_nb(unsigned short port); // socket+bind+listen, 논블로킹; 리스닝 fd 또는 -1
int tcp_accept_nb(int listenfd);
// 논블로킹 accept(); 아직 대기 중인 것이 없으면 -1/EAGAIN — 호출자는
// await_fd(listenfd, SCHED_READ) 후 재시도해야 한다.
int tcp_connect_nb(unsigned int addr_network_order, unsigned short port);
// 논블로킹 connect(); 즉시 EINPROGRESS와 함께 반환 — 호출자는
// await_fd(fd, SCHED_WRITE)를 사용하고 쓰기 가능 상태를 "연결 완료"로
// 취급해야 한다(성공/실패를 구분하려면 SO_ERROR를 확인).
int tcp_wait_connected(int fd);
// tcp_connect_nb 핸드셰이크가 끝날 때까지 블록: 연결되면 0, 실패하면 -1
// (동기 호출자용; Reactor 태스크는 대신 SCHED_WRITE를 await한다).
int tcp_connect_timeout(unsigned int addr_network_order, unsigned short port,
int timeout_ms);
// 최대 timeout_ms 동안 기다리며 연결(<= 0: 추가 제한 없음); 블로킹 모드로
// 돌린 연결된 소켓을 반환하거나 -1(소켓은 닫힘, 시간 초과면 ETIMEDOUT /
// WSAETIMEDOUT, 아니면 연결 오류 자체).
int sock_set_io_timeout(int fd, int timeout_ms);
// SO_RCVTIMEO + SO_SNDTIMEO: timeout_ms 동안 진전이 없는 읽기/쓰기 한 번이
// 블록 대신 실패한다; <= 0이면 해제. 성공 시 0.
int sock_would_block();
// 마지막 논블로킹 소켓 호출이 블록될 상황이었기 때문에만 실패했으면 1
// (errno EAGAIN/EWOULDBLOCK, Windows는 WSAEWOULDBLOCK). 실패한 호출
// 직후에 호출한다.
각 백엔드는 해당 플랫폼의 실제 sockaddr_in 레이아웃에 맞는 자신만의
struct SockAddrIn도 선언합니다 — BSD/macOS는 Linux와 Windows에는 없는
1바이트 sin_len 필드가 앞에 있고(둘 다 대신 평범한 2바이트
sin_family를 사용), 따라서 세 가지는 서로 호환되지 않습니다 — 포함한
백엔드 파일이 선언하는 것을 그대로 사용합니다.
WARNING
io_nb_win32.sc의 fd_open_nb는 최선 노력(best-effort)입니다: Windows에는
O_NONBLOCK에 대응하는 논블로킹 파일 열기가 없습니다(그곳에서 진짜 비동기
파일 I/O는 FILE_FLAG_OVERLAPPED를 붙인 CreateFile과, 이 리액터의
준비 상태 기반 모델과는 완전히 다른 완료 기반 모델을 의미하며 — 이는
여기서 다루는 범위 밖입니다). 따라서 이 백엔드에서 이 함수를 통해 연 파일은
평범한 블로킹 파일 디스크립터입니다. tcp_listen_nb/tcp_connect_nb에서
얻은 소켓이 실제로 갖는 논블로킹 의미를 기대하며 fd_open_nb로 얻은
Windows 파일 핸들에 await_fd()를 호출하지 말 것 — 소켓은 세 백엔드
모두에서 완전히 논블로킹입니다.
검증 상태
리액터 백엔드와 동일한 상태입니다: io_nb_bsd.sc는 macOS에서(실제
tcp_listen_nb/bind/listen 호출 포함), io_nb_linux.sc는 Linux에서 std
테스트 스위트로 종단 간 검증되었습니다. io_nb_win32.sc는 컴파일되고
크로스 타겟 검증(--target을 통한 실제 오브젝트 코드 생성,
static_assert로 검사한 SockAddrIn 레이아웃)까지 되었습니다.
스케줄링 모델
리액터 자체의 스케줄링은 태스크 수준에서 라운드 로빈/FIFO 방식입니다
(Task[] 배열 순서), 이는 평범한 TaskScheduler::run_all()과
동일합니다 — 유휴 상태일 때 CPU를 전혀 쓰지 않는 대기를 추가할 뿐,
우선순위나 데드라인 순서를 매기지는 않습니다. 진정한 우선순위 스케줄링은
ThreadSched에
별도로 존재하며, 이는 동일한 내부 TaskScheduler를 감싸지만 현재는
리액터에 연결되어 있지 않습니다 — 오늘날 이 둘은 하나의 타입으로 결합되어
있지 않습니다.