kernel-object와 mach-port 정리
CFRunLoop.c를 읽다 보면 mach port, timer port, wakeUpPort, source1 같은 용어가 계속 나옴.
이 용어들을 이해하려면 운영체제의 커널 객체 개념이 먼저 필요해서 일부만 발췌해서 정리.
해당 글을 Apple이 공개한 swift-corelibs-foundation의 Sources/CoreFoundation/CFRunLoop.c 기반으로 작성.
커널 객체란?
커널 객체(kernel object)는 앱 프로세스 안의 Swift/C 객체가 아니라, 커널 안에 존재하고 커널이 직접 관리하는 시스템 자원.
앱은 그 객체를 직접 만지지 못하지만 파일 디스크립터나 port name 같은 `권한 있는 이름`으로만 간접 접근.
user space와 kernel space
운영체제는 앱이 하드웨어와 시스템 자원을 자유롭게 건드는 것을 막음.

이 분리가 필요한 이유:
- 보호와 권한 검사: 앱 A가 앱 B의 메모리를 건드리지 못하게 막고, 파일을 읽어도 되는지 커널이 검사함
- 공유 자원 조정: CPU, 메모리, 디스크, 네트워크는 여러 프로세스가 나눠 씀
- 장애 격리: 앱 하나가 죽어도 커널과 다른 앱은 무너지지 않음
앱은 커널 메모리를 직접 읽고 쓸 수 없음. 대신 open, read, socket, mach_msg, pthread_create 같은 API로 커널에 요청함.
이렇게 경계를 넘는 호출이 system call이라고 부름.
커널 객체의 정체
커널 객체는 커널 내부 자료구조. 대체로 상태, 권한, 대기 중인 스레드 같은 정보를 가짐.

앱은 커널 객체의 주소를 갖지 않으며, 앱이 들고 있는 값은 아래의 형태와 같음.
fd = 5
mach_port_t = 3075
Thread 객체 = Foundation 래퍼
FileHandle 객체 = Foundation 래퍼
전부 커널 객체 그 자체가 아니라, 커널 객체를 참조하거나 제어하기 위한 핸들(handle), 이름(name), 래퍼(wrapper)임.
앱 객체, 핸들, 커널 객체 구분하기
Swift에서 만들었는데 왜 커널 객체라고 부를까?
let thread = Thread {
print("work")
}
thread.start()
여기에는 두 층이 있고, 둘 사이를 핸들이 이어줌.

Thread 객체는 앱이 만든 Foundation 래퍼. 하지만 실제 CPU 위에서 실행되는 스레드의 생명주기와 스케줄링은 커널이 관리함.
이는 Socket도 마찬가지.
let fd = socket(AF_INET, SOCK_STREAM, 0)
앱이 보는 것은 fd 숫자 하나지만, 커널 안에는 receive buffer와 연결 상태 같은 훨씬 많은 상태가 있음.

따라서 앱이 커널에 생성을 요청하고, 커널이 실제 시스템 자원을 만든 뒤, 앱에게 그 자원을 사용할 수 있는 핸들을 돌려주는 형태임
자원의 상태 전이와 권한 검사는 전부 커널 담당. 그래서 `커널 객체`라고 부름.
포인터가 아닌 핸들
핸들은 커널 객체를 가리키는 앱 측 식별자일 뿐, 커널 객체의 실제 주소가 아님.

fd = 5라는 숫자는 해당 프로세스 안에서만 의미가 있음. 다른 프로세스의 fd = 5는 완전히 다른 파일일 수 있음.
Mach port도 같은 구조. task-local name table을 통해 해석됨.

mach_port_t 숫자도 포트 객체 그 자체가 아니라, 현재 task 안에서 특정 Mach port 권한을 가리키는 이름임.
커널의 대기 모델
커널 객체의 수명은 대체로 아래 흐름.
1. 생성 요청: open(), socket(), mach_port_allocate()...
2. 커널 객체 생성: 커널이 내부 자료구조를 만들고 초기 상태 설정
3. 핸들 반환: 앱은 fd, port name 같은 값을 받음
4. 사용: read/write/send/receive 같은 system call
5. 대기: 조건이 충족되지 않으면 커널이 스레드를 재움
6. 깨우기: 데이터 도착, 메시지 도착, 타이머 만료 등으로 커널이 깨움
7. 해제: close(), 권한 소멸, ref count 0 등으로 정리
RunLoop에서 중요한 것은 5번과 6번이 가장 핵심.
while true로 `이벤트가 왔는지 여부`를 계속 확인하는 구조는 이벤트가 없어도 CPU와 배터리를 소모함.
단, 커널의 대기 모델은 정반대.
앱: 이벤트가 오면 깨워줘.
커널: 알겠어. 그동안 이 스레드는 재워둘게.
이 모델의 바탕에는 스레드 상태가 있음.
running : 지금 CPU에서 실행 중
runnable : 실행 가능하지만 CPU 차례를 기다리는 중
waiting : 이벤트(락, I/O, 메시지, 타이머)를 기다리며 잠든 상태 (= blocked)
terminated: 종료됨
커널 스케줄러는 runnable thread 중 하나를 CPU에 올림.
waiting thread는 이벤트가 오기 전까지 CPU 시간을 받지 않음.

대기 가능한 커널 객체의 예시는 2가지 예시


Socket: read(fd, ...)를 호출했는데 받을 데이터가 없으면 커널이 스레드를 재움. 패킷이 도착해 receive buffer가 차면 커널이 깨움.
Timer: 앱이 타이머를 만들면 커널 또는 하위 런타임이 deadline을 관리함. 앱이 매 순간 시간을 확인하지 않음.
RunLoop 타이머라면 CFRunLoop가 다음 deadline을 계산하고, 그 시각에 깨어나도록 timer port나 dispatch timer source를 arm함. arm은 계산된 deadline을 커널 또는 런타임 타이머에 등록하는 동작.

Mach와 Mach port
macOS/iOS의 커널인 XNU는 크게 Mach, BSD, I/O Kit 계층을 포함함.

Mach port는 Mach IPC의 기본 단위로
Mach port = 커널이 관리하는 메시지 큐 + 권한(right)을 가진 IPC endpoint
여기서 `port`는 TCP/UDP 포트 번호와 무관함. 이름만 같고 다른 개념.
Mach port object는 커널 안에서 메시지 큐, right 상태, 대기 중인 receiver thread 정보를 가짐.

보내는 쪽에는 send right, 받는 쪽에는 receive right가 필요함. 커널은 메시지를 port의 큐에 넣고, 그 port에서 기다리는 스레드가 있으면 깨움.
Mach port right
Mach port는 단순한 번호가 아니라 `권한 모델`을 가짐
| right | 의미 |
| receive right | 이 port에서 메시지를 받을 수 있는 권한 |
| send right | 이 port로 메시지를 보낼 수 있는 권한 |
| send-once right | 한 번만 보낼 수 있는 권한. reply 등에 사용 |
| port set membership | right라기보다, receive right를 port set에 넣어 여러 port를 함께 기다리게 하는 관계 |
해당 부분을 구성하는 것은 3가지 핵심 규칙에 따라 구성됨.
- send right는 여러 task가 동시에 가질 수 있음
- receive right는 복제되지 않음. 보유자는 항상 하나뿐. 다만 메시지에 실려 다른 task로 이동 중(in transit)이거나 잠시 어디에도 속하지 않은 상태(in limbo)일 수는 있음
- receive right가 최종 소멸하면 port는 dead 상태가 되어 더 이상 메시지 큐로 동작하지 않음

동일한 ipc_port가 Task A에서는 100, Task B에서는 200이라는 서로 다른 name으로 등록될 수 있음.
그래서 mach_port_t 숫자 자체에는 전역적 의미가 없으며, 정말 핵심은 중요한 것은 그 숫자가 현재 task의 port name table에서 어떤 right로 해석되는지 여부임.
샘플 코드 (right 만들고 주고받기)
같은 task 안에서 아래 순서로 동작하는 예제.
- 새 Mach port의 receive right 생성
- 같은 port에 대한 send right 추가
- 자기 자신에게 빈 Mach message 전송
- 같은 port에서 다시 수신
#include <mach/mach.h>
#include <stdio.h>
int main(void) {
mach_port_t port = MACH_PORT_NULL;
// 1. receive right 생성: 이 task가 이 port에서 메시지를 받을 수 있게 됨
kern_return_t kr = mach_port_allocate(
mach_task_self(),
MACH_PORT_RIGHT_RECEIVE,
&port
);
if (kr != KERN_SUCCESS) {
printf("mach_port_allocate failed: %d\n", kr);
return 1;
}
// 2. send right 추가: 같은 port로 메시지를 보낼 권한 생성
kr = mach_port_insert_right(
mach_task_self(),
port,
port,
MACH_MSG_TYPE_MAKE_SEND
);
if (kr != KERN_SUCCESS) {
printf("mach_port_insert_right failed: %d\n", kr);
return 1;
}
// 3. 메시지 전송. body 없는 header-only 메시지
mach_msg_header_t send_msg = {0};
send_msg.msgh_bits = MACH_MSGH_BITS(MACH_MSG_TYPE_COPY_SEND, 0);
send_msg.msgh_size = sizeof(send_msg);
send_msg.msgh_remote_port = port; // send right 사용
send_msg.msgh_local_port = MACH_PORT_NULL;
send_msg.msgh_id = 100;
kr = mach_msg(
&send_msg,
MACH_SEND_MSG,
send_msg.msgh_size,
0,
MACH_PORT_NULL,
MACH_MSG_TIMEOUT_NONE,
MACH_PORT_NULL
);
if (kr != KERN_SUCCESS) {
printf("mach_msg send failed: %d\n", kr);
return 1;
}
// 4. 같은 port에서 메시지 수신. receive right 사용
// 수신 버퍼는 header만이 아니라 커널이 덧붙이는 trailer 공간까지 필요함
// header 크기만큼만 잡으면 MACH_RCV_TOO_LARGE(0x10004004)로 실패함
struct {
mach_msg_header_t header;
mach_msg_trailer_t trailer;
} recv_msg = {0};
kr = mach_msg(
&recv_msg.header,
MACH_RCV_MSG,
0,
sizeof(recv_msg),
port,
MACH_MSG_TIMEOUT_NONE,
MACH_PORT_NULL
);
if (kr != KERN_SUCCESS) {
printf("mach_msg receive failed: 0x%x\n", kr);
return 1;
}
printf("received message id: %d\n", recv_msg.header.msgh_id);
// 5. 정리: send right 참조 1개 반납 + receive right 1개 제거
// mach_port_destroy는 macOS 12부터 deprecated(unsafe)라 아래 두 API 사용
mach_port_deallocate(mach_task_self(), port);
mach_port_mod_refs(mach_task_self(), port, MACH_PORT_RIGHT_RECEIVE, -1);
return 0;
}
위 예제에서는 3가지 정도만 주목
- port라는 숫자는 커널 객체가 아니라 현재 task의 port name table에 들어 있는 이름. 같은 값이라도 API에 따라 send right를 쓰기도, receive right를 쓰기도 함
- 수신 버퍼는 header 크기만으로 부족함. 커널이 메시지 뒤에 trailer(수신 메타데이터)를 붙이기 때문. 기본 trailer는 type/size를 담고, 요청 옵션에 따라 sequence number, audit token 같은 정보가 확장될 수 있음
- mach_port_destroy는 macOS 12부터 deprecated(unsafe). send right는 mach_port_deallocate로 반납하고, receive right는 mach_port_mod_refs(..., MACH_PORT_RIGHT_RECEIVE, -1)로 제거
mach_msg (RunLoop가 잠드는 지점)
Mach IPC의 중심 함수는 mach_msg. 역할은 크게 두 가지가 존재함.
mach_msg(..., MACH_SEND_MSG, ...); // 메시지 보내기
mach_msg(..., MACH_RCV_MSG, ...); // 메시지 받기
receive 쪽 의미는 아래와 같음.
- port에 메시지가 올 때까지 현재 스레드를 커널 안에서 재움. 메시지가 오거나 timeout이 되면 복귀.
이때 스레드는 CPU를 쓰지 않으며, 커널의 wait queue에 들어가고, 스케줄러는 다른 runnable thread에게 CPU를 줌.
// CFRunLoop.c: __CFRunLoopServiceMachPort()
for (;;) {
mach_msg_header_t *msg = (mach_msg_header_t *)*buffer;
msg->msgh_local_port = port; // 기다릴 port (또는 port set)
...
ret = mach_msg(msg,
MACH_RCV_MSG | MACH_RCV_VOUCHER | MACH_RCV_LARGE | ...,
0, msg->msgh_size, port, timeout, MACH_PORT_NULL);
...
if (MACH_MSG_SUCCESS == ret) {
// 받은 메시지의 msgh_local_port = 실제로 메시지가 도착한 port
// 이것이 "무엇이 깨웠는지"를 알려주는 livePort가 됨
*livePort = msg ? msg->msgh_local_port : MACH_PORT_NULL;
return true;
}
...
}
RunLoop thread는 이 mach_msg에서 blocking receive에 들어감. 호출이 반환되면 받은 메시지의 msgh_local_port로 어느 포트에 메시지가 왔는지 확인함.
Port set (여러 port를 한 번에 기다리기)
RunLoop는 하나의 port만 기다리지 않음. 타이머, wakeup, dispatch, source1 이벤트를 전부 기다려야 함.
이때 쓰는 것이 port set. 여러 port를 하나로 묶고 그 묶음 전체에 mach_msg receive를 하면, 그중 어느 port에 메시지가 와도 깨어남.
// CFRunLoop.c: __CFRunLoopFindMode() (mode 최초 생성 시)
rlm->_portSet = __CFPortSetAllocate(); // mode의 port set 생성
// (USE_DISPATCH_SOURCE_FOR_TIMERS 빌드) mode 전용 dispatch queue의 port도 상주
__CFPort queuePort = _dispatch_runloop_root_queue_get_port_4CF(rlm->_queue);
ret = __CFPortSetInsert(queuePort, rlm->_portSet);
rlm->_timerPort = mk_timer_create(); // 타이머용 port 생성
ret = __CFPortSetInsert(rlm->_timerPort, rlm->_portSet); // timer port 상주
ret = __CFPortSetInsert(rl->_wakeUpPort, rlm->_portSet); // wakeUpPort 상주
wakeUpPort, _timerPort, 타이머용 mode queue port는 mode가 만들어지는 순간부터 _portSet에 상주함. Source1 포트는 source가 추가될 때 들어감.
dispatch main queue port만 다르게 처리됨. 상주하지 않고, 잠들기 직전에 넣었다가 깨어나면 뺌. __CFRunLoopRun() 발췌.
// CFRunLoop.c: __CFRunLoopRun()
// 잠들기 전: dispatch port를 non-blocking(timeout 0)으로 먼저 확인
// 이미 처리할 것이 있으면 잠들지 않고 바로 처리하러 감
if (CFPORT_NULL != dispatchPort && !didDispatchPortLastTime) {
msg = (mach_msg_header_t *)msg_buffer;
if (__CFRunLoopServiceMachPort(dispatchPort, &msg, sizeof(msg_buffer),
&livePort, 0, ...)) {
goto handle_msg;
}
}
...
__CFPortSetInsert(dispatchPort, waitSet); // 잠드는 동안에만 set에 삽입
...
// (여기서 mach_msg로 잠듦)
...
__CFPortSetRemove(dispatchPort, waitSet); // 깨어나면 바로 제거
타이머가 깨우는 경로는 두가지 경로로 갈라지는데 __CFArmNextTimerInMode()에서 갈라짐.
// CFRunLoop.c: __CFArmNextTimerInMode()
uint64_t leeway = __CFTSRToNanoseconds(nextHardDeadline - nextSoftDeadline);
if (leeway > 0) {
// tolerance가 있는 타이머 → dispatch timer source로 깨움
// (실제 wakeup 메시지는 위에서 상주시킨 mode queue port로 들어옴)
dispatch_source_set_timer(rlm->_timerSource, deadline, DISPATCH_TIME_FOREVER, leeway);
rlm->_dispatchTimerArmed = true;
} else {
// tolerance가 0인 타이머 → mk_timer로 _timerPort를 arm
if (rlm->_timerPort) {
mk_timer_arm(rlm->_timerPort, nextSoftDeadline);
rlm->_mkTimerArmed = true;
}
}
leeway는 타이머의 tolerance에서 계산됨. Timer의 tolerance 기본값은 0이므로, 별도 지정이 없는 보통의 타이머는 _timerPort(mk_timer) 경로로 깨어남.
RunLoop가 기다리는 포트를 정리하면 아래와 같음.

RunLoop는 이 포트들을 대상으로 mach_msg receive를 하고, 깨어난 뒤 livePort로 어떤 포트가 깨웠는지 구분함.

RunLoop의 잠들기와 깨어나기
지금까지의 내용을 RunLoop 흐름으로 연결하면 아래와 같음.
1. RunLoop mode는 여러 Mach port를 port set에 묶어둠
2. 지금 처리 가능한 Source0, block, timer 등을 먼저 처리
3. 더 할 일이 없으면 mach_msg(MACH_RCV_MSG)로 port set을 기다림
→ 커널은 thread를 waiting 상태로 만들고 CPU에서 내려놓음
4. 타이머 만료, dispatch main queue, Source1, wakeUpPort 중
하나가 신호를 보내면 커널이 thread를 깨움
5. livePort를 확인해 어떤 종류의 이벤트인지 분기해 처리
6. 다시 2번으로
CFRunLoopMode에는 이 구조가 그대로 들어 있음.

잠든 RunLoop를 깨울 때는 CFRunLoopWakeUp을 사용. 내부에서 _wakeUpPort로 빈 메시지 하나를 보냄.
// CFRunLoop.c: CFRunLoopWakeUp()
/* We unconditionally try to send the message, since we don't want
* to lose a wakeup, but the send may fail if there is already a
* wakeup pending, since the queue length is 1. */
ret = __CFSendTrivialMachMessage(rl->_wakeUpPort, 0, MACH_SEND_TIMEOUT, 0);
주석에 따르면 _wakeUpPort의 메시지 큐 길이는 1.
이미 wakeup 메시지가 대기 중이면 새 메시지는 실패할 수 있음. 그래도 pending wakeup이 하나라도 있으면 깨우는 조건은 이미 만족된 상태라 이 실패는 무시됨.
그래서 RunLoop를 단순한 while true로 보면 중요한 부분을 놓침.
부정확한 설명:
계속 돌면서 이벤트를 확인함
정확한 설명:
처리할 일을 처리한 뒤, 커널 객체에 대기를 맡기고 스레드를 재움
이벤트가 없으면 CPU 사용량이 거의 0에 가깝고 그리고 타이머/IPC/수동 wakeup이 하나의 대기 모델로 묶이는 이유가 모두 여기에 있음.
Source0와 Source1
Source0/Source1 차이는 `커널이 직접 깨울 수 있느냐`를 기준으로 구분할 수 있음.
| 구분 | Source0 | Source1 |
| 기반 | 앱 내부 signal flag | Mach port |
| 커널 대기 대상인가 | 아니오 | 예 |
| 잠든 RunLoop를 자동으로 깨우나 | 아니오 | 포트 메시지로 깨움 |
| 보통 필요한 작업 | CFRunLoopSourceSignal + CFRunLoopWakeUp | port 메시지 수신 |
| 처리 위치 | 잠들기 전 Source0 처리 단계 | 깨어난 뒤 livePort 분기 |
Source0에는 포트가 없음. `할 일 있음` 표시를 해도 RunLoop가 이미 자고 있으면 알아차리지 못함.
그래서 CFRunLoopWakeUp으로 직접 깨워주어야 함.

Source1에는 port가 있음. 메시지가 도착하는 순간 커널이 RunLoop thread를 깨움.

핸들이 아니라 권한 (Mach port)
Mach port를 이해할 때는 `핸들`보다 `권한(capability)` 관점이 더 정확함.
파일 디스크립터가 `이 열린 파일에 read/write를 요청할 수 있는 핸들`이라면, Mach port name은 `이 port에 대해 특정 right를 행사할 수 있는 이름`임.

권한은 전달도 가능하며, Mach 메시지 안에 port right를 담아 다른 task에게 넘길 수 있음.
이 점이 단순한 정수 ID와 다름.
단순 ID:
"3075번 포트에 보내자"
Mach right:
"내 task namespace에서 3075라는 이름으로 등록된
send right를 사용해 메시지를 보내자"
커널은 이 right를 보고 허용/거부를 결정함.
최종 정리 !

- Swift에서 만든 Thread를 커널 객체라고 부르는 이유: 앱이 만드는 것은 래퍼이고, 실제 자원과 스케줄링은 커널 안에 있기 때문
- mach_port_t의 정체: 포트 객체가 아니라, 현재 task의 port name table에서 특정 right를 가리키는 이름
- RunLoop가 CPU를 거의 쓰지 않는 이유: port set을 대상으로 mach_msg receive에 들어가 커널 수준에서 잠들고, 메시지가 도착할 때만 커널이 깨우기 때문
추가 내용
- 스레드가 잠든다는 것은 커널 스케줄러가 그 thread를 waiting 상태로 두고 CPU에서 빼는 일
- Source0는 포트가 없어 잠든 RunLoop를 스스로 깨우지 못하고, Source1은 Mach port 기반이라 커널이 직접 깨움
- CFRunLoopWakeUp은 wakeUpPort에 빈 메시지를 보내 자던 RunLoop를 깨움
'Apple > iOS, UIKit, Documentation' 카테고리의 다른 글
| swift rethrows (0) | 2025.12.03 |
|---|---|
| AssetInventory 정리 + AssetInstallationRequest (0) | 2025.11.22 |
| SpeechAnalyzer (0) | 2025.11.22 |
| SpeechTranscriber 정리 + DictationTranscriber (0) | 2025.11.22 |
| sending parameter and result values (0) | 2025.10.07 |