macOS select(2)가 오버플로우되지 않는 상황에 대해
배경
<커널의 IO 모델>에서 select 시스템 콜은 다른 IO 멀티플렉싱 코드보다 성능이 떨어짐을 밝히기 위해 select와 poll, epoll/kqueue를 비교하는 성능 테스트를 진행했었다.
이 테스트에서 select는 수용 가능한 파일 디스크립터 값을 초과하여 동작이 시도되어 undefined behavior가 발생했어야 함에도 불구, 코드의 panic이 발생하지 않음을 확인했다.
심지어 이는 -O2 -D_FORTIFY_SOURCE=2 옵션을 사용하여 빌드한 경우에도 마찬가지였다.
1
2
3
4
5
6
7
8
❯ gcc -O2 -D_FORTIFY_SOURCE=2 io_multi_perf_test.c
❯ ./a.out
Supports: kqueue
FD: 5000, Loop: 10000
select, avg 358.35 us
poll, avg 540.17 us
kqueue, avg 0.53 us
상세
macOS가 커널로 사용하고 있는 XNU는 apple-oss-distribution/xnu 저장소에서 코드를 확인할 수 있다.
select선언:xnu/bsd/sys/_select.hselect.h:xnu/bsd/sys/select.h(select선언을 include)select구현:xnu/bsd/kern/sys_generic.c: 1128select_nocancel:xnu/bsd/kern/sys_generic.c: 1135 (select가 호출)select_internal:xnu/bsd/kern/sys_generic.c: 1276 (select_nocancel가 호출)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// /bsd/kern/sys_generic.c:1317 (rel/xnu-12377)
/*
* get the bits from the user address space
*/
#define getbits(name, x) \
(uap->name ? copyin(uap->name, &sel->ibits[(x) * nw], ni) : 0)
if ((error = getbits(in, 0))) {
return error;
}
if ((error = getbits(ou, 1))) {
return error;
}
if ((error = getbits(ex, 2))) {
return error;
}
#undef getbits
발췌: sys_generic.c:1317; select가 호출되면, 구현에서 커널 공간으로 마스크를 복사하는 것을 알 수 있다.
1
2
3
4
// /bsd/kern/sys_generic.c:1331 (rel/xnu-12377)
if ((error = selcount(p, sel->ibits, uap->nd, &seldata->count))) {
return error;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// /bsd/kern/sys_generic.c:1940 (rel/xnu-12377)
selcount(struct proc *p, u_int32_t *ibits, int nfd, int *countp)
{
/* ... */
for (msk = 0; msk < 3; msk++) {
/* ... */
for (i = 0; i < nfd; i += NFDBITS) {
/* ... */
while ((j = ffs(bits)) && (fd = i + --j) < nfd) {
bits &= ~(1U << j);
fp = fp_get_noref_locked(p, fd);
if (fp == NULL) {
*countp = 0;
error = EBADF;
goto bad;
}
os_ref_retain_locked(&fp->fp_iocount);
n++;
}
}
}
/* ... */
}
select는 selcount를 호출해 커널공간으로 복사된 마스크로부터 감시할 디스크립터를 찾게 한다. selcount는 실제로 루프를 돌며 감시할 디스크립터를 찾는다. 이 단계에서 select의 동작은 일반적으로 널리 통용되는 select의 동작과 크게 다르지 않은 것으로 보인다.
FD_SET 매크로
1
2
3
4
5
6
7
8
for (j = 0; j < MAX_FD_COUNT; j++)
{
FD_SET(pipes[j][0], &readfds);
if (pipes[j][0] > max_fd)
max_fd = pipes[j][0];
}
/* ... */
select(max_fd + 1, &readfds, NULL, NULL, &timeout);
앞서 언급한 실험 코드에서는 for문에서 실험 설정값만큼의 파이프를 생성하여 생성한 파일 디스크립터 값들 중 최대값을 select로 호출했다. 이 과정에서 FD_SET 매크로로 select의 추적 대상 파일 디스크립터를 설정했다.
1
2
3
4
5
6
7
8
9
10
11
// /bsd/sys/_types/_fd_set.h:30 (rel/xnu-12377)
#define FD_SET(n, p) __DARWIN_FD_SET(n, p)
// /bsd/sys/_types/_fd_def.h:131 (rel/xnu-12377)
#define __DARWIN_FD_SET(n, p) __darwin_fd_set((n), (p))
// /bsd/sys/_types/_fd_def.h:95 (rel/xnu-12377)
__darwin_fd_set(int _fd, struct fd_set *const _p)
{
if (__darwin_check_fd_set(_fd, (const void *) _p)) {
(_p->fds_bits[(unsigned long)_fd / __DARWIN_NFDBITS] |= ((__int32_t)(((unsigned long)1) << ((unsigned long)_fd % __DARWIN_NFDBITS))));
}
}
발췌:
FD_SET에 의해 호출되는 __darwin_fd_set은 __darwin_check_fd_set을 호출하여, 반환한 값이 0이라면 별다른 행동을 하지 않고 호출을 종료한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// /bsd/sys/_types/_fd_def.h:63 (rel/xnu-12377)
__darwin_check_fd_set(int _a, const void *_b)
{
/* ... */
if ((uintptr_t)&__darwin_check_fd_set_overflow != (uintptr_t) 0) {
/* ... */
return __darwin_check_fd_set_overflow(_a, _b, 1);
} /* ... */ {
return __darwin_check_fd_set_overflow(_a, _b, 0);
/* ... */
} else {
return 1;
/* ... */
}
}
발췌: xnu/bsd/sys/_types/_fd_def.h:63
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// /libsyscall/wrappers/terminate_with_reason.c (rel/xnu-12377)
int
__darwin_check_fd_set_overflow(int n, const void *fd_set, int unlimited_select)
{
/* ... */
if (n >= __DARWIN_FD_SETSIZE) {
if (/* ... */) {
if (/* ... */) {
/* ... */
return 0;
} else {
return 1;
}
} else {
return 1;
}
}
return 1;
}
발췌: xnu/libsyscall/wrappers/terminate_with_reason.c:34
_darwin_check_fd_set은 다시 __darwin_check_fd_set_overflow를 호출한다. 이 함수는 _a가 FD_SETSIZE(__DARWIN_FD_SETSIZE)보다 크거나 작은지 확인하고, 그에 따라 0 또는 1을 반환한다.
정리해서, macOS에서 FD_SET 매크로는 내부적으로 처리 가능한 파일 디스크립터 값을 초과하는 경우, 별다른 동작을 하지 않고 호출을 종료한다.
때문에 감시할 파일 디스크립터 목록을 FD_SET 매크로로 생성하면 처리 불가능한 파일 디스크립터 값이 무시되어, select가 호출될 때 undefined behavior가 발생하지 않았다.
(추가) macOS 환경 실험에서 select와 poll의 성능 차이 값
Macbook Air (M4, 2024):
- 환경
- Darwin 25.6.0
- Kernel: 25.6.0 arch: arm64
- 10-core, Apple M4
- 결과
- Supports: kqueue
select, avg 377.21 uspoll, avg 531.76 uskqueue, avg 0.57 us
앞서 수행한 실험에서, poll은 select의 성능 개선 구현임에도 불구하고 select보다 호출 처리에 오랜 시간이 소요된 것이 확인되었다. 이것 역시 select가 호출되기 이전에 FD_SET에 의해 처리 불가능한 파일 디스크립터 값이 무시되어, select가 처리할 값이 poll보다 적었기 때문으로 추정된다.
(추가) FD_SET을 사용하지 않은 파일 디스크립터 목록을 select에 전달
FD_SET 매크로에 의해 처리 불가능한 파일 디스크립터 값이 무시되었기 때문에, FD_SET 매크로를 사용하지 않고 직접 지정한 값을 select로 호출해보았다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
for (i = 0; i < ITERATIONS; i++)
{
fd_set readfds;
FD_ZERO(&readfds);
FD_SET(pipes[0][0], &readfds); // FD_SETSIZE 이내의 정상적인 fd 하나만 등록
write(pipes[MAX_FD_COUNT - 1][1], &dummy, 1);
struct timeval timeout = {0, 0};
int ret = select(10000, &readfds, NULL, NULL, &timeout); // nfds만 부풀림
if (ret < 0) {
perror("select");
}
read(pipes[MAX_FD_COUNT - 1][0], &dummy, 1);
}
1
2
select: Invalid argument
(...10000회 반복)
그 결과 select가 의도하지 않은 동작을 시도하였고, undefined behavior를 막기 위해 XNU 커널에 구현된 차단 로직에 의해 Invalid argument 오류가 발생했다.