본문 바로가기

성능 튜닝과 OS 면접 주제

파일 디스크립터 한도, TCP, 메모리, I/O 스케줄러의 커널 파라미터를 측정에서 출발해 조정하는 방법을 정리하고, fd 번호 한도와 캐시 미스를 모델로 확인합니다. 운영체제 면접에서 자주 나오는 질문을 이 교재의 각 장과 연결해 답해 봅니다.

이 글은 두 부분으로 나뉩니다. 앞부분에서는 서버를 운영하며 자주 만지는 커널 파라미터를, 뒷부분에서는 운영체제 면접에서 자주 나오는 질문을 다룹니다. 둘은 같은 이야기의 앞뒤입니다. 파라미터 하나를 바꾸는 이유를 설명하려면 그 아래의 운영체제 동작을 알아야 하고, 면접 질문도 결국 그 동작을 물어보기 때문입니다.

튜닝은 측정에서 시작한다

인터넷에서 찾은 "권장 설정"을 한꺼번에 적용하는 것은 튜닝이 아닙니다. 어떤 값이 좋은지는 하드웨어와 부하에 따라 달라서, 어떤 서버에서 좋았던 값이 다른 서버에서는 오히려 나쁠 수 있습니다. 튜닝은 앞 글의 방법으로 병목을 찾고, 그 병목에 맞는 설정 하나를 바꾼 뒤, 같은 부하에서 처리량과 p95, p99 지연을 비교하는 일의 반복입니다. 바꾸기 전의 값을 기록해 두어 언제든 되돌릴 수 있어야 합니다.

sysctl -w로 바꾼 값은 즉시 적용되지만 재부팅하면 사라집니다. 오래 유지할 값은 /etc/sysctl.d/ 아래에 파일로 적고 sudo sysctl --system으로 다시 읽게 합니다.

파일 디스크립터 한도

소켓도 파일 디스크립터를 쓰므로, 동시에 연결 수천 개를 다루는 서버는 fd 한도에 먼저 걸립니다. 한도를 넘으면 open()이나 accept()가 EMFILE("Too many open files")로 실패합니다.

ulimit -n                     # 이 셸의 soft 한도
ulimit -Hn                    # hard 한도(일반 사용자는 이 값까지만 올릴 수 있다)
cat /proc/PID/limits          # 실행 중인 프로세스에 실제로 걸린 한도
ls /proc/PID/fd | wc -l       # 그 프로세스가 지금 연 fd 수
cat /proc/sys/fs/file-max     # 시스템 전체에서 열 수 있는 파일 수

한도를 어디에 적는지는 프로세스를 어떻게 시작하느냐에 따라 다릅니다. 로그인 세션에는 PAM이 /etc/security/limits.conf를, systemd 서비스에는 14장 1편에서 본 유닛 파일의 LimitNOFILE이 적용되므로, 서비스의 한도를 limits.conf로 바꾸려 하면 아무 효과가 없습니다. getrlimit(2)에 따르면 이 한도는 정확히는 열 수 있는 fd 번호의 최댓값보다 하나 큰 값입니다.

def open_fds(in_use, limit, wanted):
    """가장 작은 빈 번호를 주되, 그 번호가 한도 이상이면 EMFILE로 실패한다."""
    opened = []
    for _ in range(wanted):
        fd = min(n for n in range(limit + 1) if n not in in_use)
        if fd >= limit:
            return opened, "EMFILE(Too many open files)"
        in_use.add(fd)
        opened.append(fd)
    return opened, "성공"

for limit in (113, 114):
    opened, result = open_fds(set(range(112)), limit, wanted=2)   # 0~111번을 쓰는 중
    print(f"한도 {limit}: 새로 받은 fd {opened}, {result}")
한도 113: 새로 받은 fd [112], EMFILE(Too many open files)
한도 114: 새로 받은 fd [112, 113], 성공

10장 4편에서 본 것처럼 새 fd는 비어 있는 가장 작은 번호를 받으므로, 0번부터 빈틈없이 쓰고 있다면 열린 수와 번호가 맞아떨어집니다. 한도를 올리기 전에 정말로 그만큼의 fd가 필요한지, 아니면 닫지 않고 새는 fd가 쌓이고 있는지를 /proc/PID/fd로 먼저 확인해야 합니다. 새는 것이라면 한도를 올려도 언젠가 다시 걸립니다.

더 깊이: 한도를 1024보다 올릴 때 조심할 select()

fd 한도를 올릴 때는 그 프로그램이 select()를 쓰는지 확인해야 합니다. select(2)에 따르면 glibc는 fd_set을 크기가 고정된 자료형으로 만들고 FD_SETSIZE를 1024로 정해 두었으므로, select()는 번호가 1024보다 작은 fd만 다룰 수 있으며 이 제한은 앞으로도 바뀌지 않습니다. 1024 이상의 번호를 FD_SET() 같은 매크로에 넘기는 것은 정의되지 않은 동작이어서, 한도를 올린 뒤 fd 번호가 커지면 오류 메시지 없이 잘못 동작할 수 있습니다. 매뉴얼은 새 프로그램에는 이런 제한이 없는 poll()이나 epoll을 쓰라고 권합니다.

systemd.exec(5)도 LimitNOFILE을 설명하면서 같은 이유로 소프트 한도를 1024보다 올릴 때 조심하라고 적고, 하드 한도의 기본값은 지금 524288이라고 밝힙니다. getrlimit(2)에 따르면 특권이 없는 프로세스도 소프트 한도는 하드 한도 안에서 스스로 올릴 수 있습니다. 그래서 fd가 많이 필요하고 select()를 쓰지 않는 프로그램은 시작할 때 setrlimit()으로 자기 소프트 한도를 올리는 방법을 쓸 수 있습니다.

TCP 파라미터

TCP 설정 · 큐와 종료 상태

accept 대기열·SYN 요청·TIME_WAIT 재사용은 서로 다른 설정 대상입니다

설정 이름을 병목 가설과 연결합니다. 특히 tcp_fin_timeout은 고아 FIN_WAIT_2의 대기 한도이며 TIME_WAIT 시간을 줄이는 설정이 아닙니다.

TCP 설정이 적용되는 대상을 구별하기
대상설정 이름적용 범위
accept 대기열somaxconn애플리케이션 listen backlog의 상한. 처리량이나 연결 성공을 보장하지 않음
미완료 연결tcp_max_syn_backlog리스너별 SYN_RECV 요청 한도. 정상 부하 대책과 SYN cookies는 구분
TIME_WAIT 재사용tcp_tw_reuse프로토콜상 안전한 재사용만 허용. 0: 끔, 1: 전체, 2: 루프백만
고아 연결 정리tcp_fin_timeout앱이 더 이상 참조하지 않는 FIN_WAIT_2 연결의 대기 한도. TIME_WAIT 시간이 아님
임시 로컬 포트ip_local_port_range자동 할당할 로컬 포트 범위. 예약 포트·목적지와 연결 상태도 고려
accept 대기열
설정 이름: somaxconn
적용 범위: 애플리케이션 listen backlog의 상한. 처리량이나 연결 성공을 보장하지 않음
미완료 연결
설정 이름: tcp_max_syn_backlog
적용 범위: 리스너별 SYN_RECV 요청 한도. 정상 부하 대책과 SYN cookies는 구분
TIME_WAIT 재사용
설정 이름: tcp_tw_reuse
적용 범위: 프로토콜상 안전한 재사용만 허용. 0: 끔, 1: 전체, 2: 루프백만
고아 연결 정리
설정 이름: tcp_fin_timeout
적용 범위: 앱이 더 이상 참조하지 않는 FIN_WAIT_2 연결의 대기 한도. TIME_WAIT 시간이 아님
임시 로컬 포트
설정 이름: ip_local_port_range
적용 범위: 자동 할당할 로컬 포트 범위. 예약 포트·목적지와 연결 상태도 고려

기본값은 커널·배포판에 따라 확인합니다. 예를 들어 somaxconn의 upstream 기본값은 Linux 5.4부터 4096이며, 위 65535는 이 예제의 값입니다.

TCP 파라미터는 이름이 비슷해도 다루는 대상이 서로 다릅니다. 그림 아래 메모의 65535는 이 글의 예제에 없는 값이며, 아래 명령은 값을 바꾸지 않고 지금 값을 읽기만 합니다.

sysctl net.core.somaxconn                 # listen()의 대기열 길이 상한
sysctl net.ipv4.tcp_max_syn_backlog       # 연결을 맺는 중인(SYN_RECV) 요청의 수
sysctl net.ipv4.tcp_tw_reuse              # 나가는 연결에서 TIME_WAIT 소켓의 재사용
sysctl net.ipv4.ip_local_port_range       # 나가는 연결에 자동으로 고르는 포트 범위
sysctl net.ipv4.tcp_keepalive_time        # 연결 유지 확인을 시작하기까지의 시간

somaxconn은 응용 프로그램이 listen()에 넘긴 대기열 길이의 상한으로, listen(2)에 따르면 이보다 큰 값은 조용히 잘리고, 기본값은 Linux 5.4부터 4096입니다. 연결이 대기열에서 넘쳐 버려지고 있다면 이 값과 함께 응용 프로그램이 listen()에 넘기는 값도 올려야 하고, 무엇보다 accept()가 연결을 늦게 꺼내는 원인을 찾아야 합니다. 커널의 ip-sysctl 문서에 따르면 tcp_tw_reuse는 0이면 끄고, 1이면 모든 연결에, 기본값인 2이면 루프백 연결에만 프로토콜상 안전한 경우 TIME_WAIT 소켓을 재사용하며, 전문가의 조언 없이 바꾸지 말라고 적혀 있습니다. ip_local_port_range의 기본값은 32768부터 60999까지입니다.

연결 유지 확인(keepalive)은 데이터가 오가지 않는 연결에 작은 확인 패킷을 보내 상대가 아직 있는지 알아보는 기능으로, 네트워크 교재의 TCP 심화와 실무에서 자세히 다룹니다. Linux의 기본값은 2시간 동안 아무 데이터가 없으면 확인 패킷을 보내기 시작해, 75초 간격으로 9번까지 보내는 것입니다. 중간의 방화벽이나 로드 밸런서가 그보다 짧게 쉬는 연결을 끊어 버린다면, 응용 프로그램이 소켓 옵션으로 더 짧은 값을 지정하거나 이 기본값을 줄입니다.

메모리 파라미터

sysctl vm.swappiness            # 스왑과 파일 페이지 회수의 상대 비용(0~200, 기본 60)
sysctl vm.overcommit_memory     # 0: 추정으로 지나친 요청만 거부, 1: 항상 허락, 2: 정해진 한도까지만
sysctl vm.dirty_background_ratio vm.dirty_ratio   # 더티 페이지의 백그라운드 쓰기와 쓰기 늦추기 기준

커널 문서에 따르면 swappiness는 스왑 I/O와 파일 시스템 I/O의 대략적인 상대 비용을 0부터 200 사이로 나타냅니다. 100이면 둘을 같은 비용으로 보고 익명 메모리와 페이지 캐시에 같은 압력을 주며, 값이 낮을수록 스왑을 비싸게 봅니다. 0이어도 스왑을 아예 끄는 것은 아니고, 메모리가 아주 모자라면 스왑을 씁니다. 지연에 민감한 데이터베이스 서버는 값을 낮추기도 하지만, 메모리 자체가 모자란다면 값을 바꾸는 것보다 메모리를 늘리거나 사용량을 줄이는 것이 해결입니다. 더티 페이지 관련 값은 10장 4편에서, I/O 스케줄러는 11장 3편에서 다루었습니다.

면접에서 자주 묻는 주제

면접 질문은 대개 이 교재의 한 장을 한두 문장으로 요약하라는 요구입니다. 외운 정의보다, 그 개념이 왜 필요하고 무엇과 맞바꾸는지를 말할 수 있어야 합니다.

프로세스와 스레드는 무엇이 다른가

프로세스는 자기만의 주소 공간과 열린 파일 같은 자원을 가진 실행 단위이고, 스레드는 한 프로세스 안에서 코드, 데이터, 힙, 열린 파일을 함께 쓰면서 스택과 레지스터만 따로 가진 실행 흐름입니다. 스레드끼리는 메모리를 바로 공유하므로 통신이 빠르지만, 그만큼 동기화가 필요하고 한 스레드의 잘못이 프로세스 전체를 무너뜨립니다. 프로세스끼리는 IPC로 통신해야 하지만 서로 격리되어 있습니다(3장, 4장).

문맥 교환에는 어떤 비용이 드나

CPU가 다른 프로세스나 스레드를 실행하려면 지금의 레지스터와 프로그램 카운터를 PCB에 저장하고 다음 것의 상태를 불러와야 합니다. 이 직접 비용보다 큰 것은 간접 비용으로, 새로 실행된 쪽이 쓰는 데이터가 캐시에 없어 한동안 캐시 미스가 늘고, 다른 프로세스로 바뀌면 주소 변환을 담은 TLB의 내용도 쓸모가 없어집니다. 같은 프로세스의 스레드 사이의 컨텍스트 스위칭은 주소 공간이 그대로여서 TLB를 비우지 않아도 됩니다(3장 2편).

fork와 exec는 어떻게 함께 쓰이나

fork()는 호출한 프로세스를 복제해 자식을 만들고, exec()는 지금 프로세스의 주소 공간을 새 프로그램으로 갈아 끼웁니다. 셸은 fork()로 자식을 만든 뒤, 자식에서 리다이렉션 같은 준비를 하고 exec()로 명령을 실행합니다. fork()가 메모리를 정말로 다 복사하면 비싸므로, 실제로는 페이지를 공유하다가 한쪽이 쓸 때만 복사하는 쓰기 시 복사(COW)를 씁니다(3장 3편).

가상 메모리와 페이지 폴트는 무엇인가

가상 메모리는 프로세스마다 독립된 주소 공간을 주고, 페이지 테이블로 가상 주소를 물리 주소에 대응시키는 기법입니다. 프로세스는 물리 메모리보다 큰 주소 공간을 쓸 수 있고, 서로의 메모리를 볼 수 없습니다. 페이지 폴트는 접근한 페이지에 대응이 없거나 권한이 맞지 않을 때 일어나는 예외로, 운영체제가 페이지를 디스크에서 읽어 오거나 COW 복사를 하거나 0으로 채운 페이지를 준 뒤 다시 실행시킵니다. 잘못된 접근이라면 프로세스에 SIGSEGV를 보냅니다(9장, 9장 3편).

내부 단편화와 외부 단편화는 어떻게 다른가

내부 단편화는 할당한 블록 안에서 쓰이지 않고 남는 공간으로, 4KiB 페이지 단위로 나누어 주는 페이징에서는 마지막 페이지의 남는 부분이 그렇습니다. 외부 단편화는 빈 공간이 여기저기 조각나 합치면 충분해도 연속된 큰 공간을 줄 수 없는 상태로, 가변 크기로 나누어 주는 연속 할당이나 세그먼테이션에서 생깁니다. 페이징은 물리적으로 연속된 공간을 요구하지 않아 외부 단편화를 피하지만, 큰 페이지나 커널의 연속 메모리 할당에서는 여전히 문제가 됩니다(8장 2편).

교착 상태는 언제 생기나

상호 배제, 점유와 대기, 비선점, 순환 대기의 네 조건이 모두 성립할 때 생기며, 하나라도 깨면 막을 수 있습니다. 실무에서는 모든 코드가 같은 순서로 잠금을 잡게 해 순환 대기를 없애거나, 잠금을 기다리는 시간에 한도를 두는 방법을 가장 많이 씁니다(7장, 7장 2편).

뮤텍스, 세마포어, 스핀락은 언제 쓰나

뮤텍스는 한 번에 하나만 들어가게 하는 잠금으로 소유자가 있어, 잠근 스레드가 풀어야 합니다. 세마포어는 정수 카운터로 동시에 N개까지 들어가게 하거나, 한 스레드가 다른 스레드에 사건을 알리는 데 쓰며 소유자가 없습니다. 스핀락은 잠들지 않고 돌면서 기다리므로, 잠금을 잡는 시간이 컨텍스트 스위칭보다 짧을 때나 잠들 수 없는 커널의 인터럽트 처리 같은 곳에서 씁니다(6장 2편, 6장 3편).

사용자 모드와 커널 모드는 왜 나누나

사용자 모드에서는 특권 명령을 실행할 수 없고 커널의 메모리에 접근할 수 없으므로, 응용 프로그램의 버그나 악의가 시스템 전체를 망가뜨리지 못합니다. 응용 프로그램은 파일을 열거나 메모리를 받을 때 시스템 콜로 커널에 요청하고, 커널은 요청과 권한을 확인한 뒤 대신 처리합니다. 모드를 오가는 데 비용이 들어서, 시스템 콜을 줄이는 버퍼링이나 io_uring 같은 기법이 쓰입니다(1장 3편).

동기와 비동기, 블로킹과 논블로킹은 어떻게 다른가

I/O 요청과 완료 대기는 다른 호출일 수 있다

비동기 읽기를 큐에 제출한다. 확인한 완료 요청의 결과를 한 번 얻는다. 각 단계의 값과 전환 이유를 비교합니다.

I/O 인터페이스 · 요청과 완료 대기

비동기 요청을 제출한 뒤에도 완료를 기다리는 호출을 사용할 수 있습니다

블로킹 여부는 개별 호출이 기다리는지를 봅니다. 비동기 I/O는 요청과 완료 확인을 나누며, 논블로킹 read도 성공하면 데이터를 그 호출에서 돌려줍니다.

비동기 요청과 별도 완료 대기 호출제출에 성공한 뒤 아직 처리 중인 요청 목록의 완료로 aio_suspend가 정상 반환하는 선택 경로다. 시간은 아래로 흐른다. 제출 성공 반환은 비동기 작업의 완료가 아니다. 이미 완료되면 대기는 즉시 반환할 수 있고 시그널·timeout에 의한 다른 반환과 제출 오류는 기존 지원 패널에 남긴다. 목록 중 완료된 요청을 aio_error로 확인한 뒤 그 요청의 aio_return을 한 번 회수한다. 점선 화살표는 호출의 반환이며 완료 callback을 뜻하지 않는다.호출 스레드비동기 요청aio_read 제출제출 성공 반환다른 작업 가능aio_suspend 대기목록 내 요청 완료상태 확인 후 aio_return 1회
비동기 요청과 별도 완료 대기 호출제출에 성공한 뒤 아직 처리 중인 요청 목록의 완료로 aio_suspend가 정상 반환하는 선택 경로다. 시간은 아래로 흐른다. 제출 성공 반환은 비동기 작업의 완료가 아니다. 이미 완료되면 대기는 즉시 반환할 수 있고 시그널·timeout에 의한 다른 반환과 제출 오류는 기존 지원 패널에 남긴다. 목록 중 완료된 요청을 aio_error로 확인한 뒤 그 요청의 aio_return을 한 번 회수한다. 점선 화살표는 호출의 반환이며 완료 callback을 뜻하지 않는다.호출스레드비동기요청aio_read 제출제출 성공 반환다른 작업 가능aio_suspend대기목록 내 요청완료상태 확인 후aio_return 1회

블로킹 read · 결과를 반환

데이터·EOF·오류 등을 반환할 수 있을 때까지 기다릴 수 있습니다.

확인: 읽은 바이트 수 또는 오류. 요청한 길이보다 적게 읽을 수도 있습니다.

논블로킹 read · 지금 가능한 결과

논블로킹이 지원되는 대상에서는 데이터 대기 없이 반환합니다. 데이터가 없으면 EAGAIN 등의 오류를 확인합니다.

확인: 바이트 수·EOF·오류. 준비 상태를 기다린 뒤 재시도할 수 있습니다.

aio_read · 요청을 제출

비동기 읽기를 큐에 제출합니다. 제출 성공은 읽기 완료나 성공을 뜻하지 않습니다.

확인: aio_error로 상태를 보고, 완료 뒤 aio_return으로 결과를 한 번 회수합니다.

aio_suspend · 별도 호출에서 대기

대상 비동기 요청 중 하나의 완료·시그널·시간 제한까지 기다릴 수 있습니다. 이미 완료된 요청이 있으면 바로 반환합니다.

확인: 각 요청의 상태를 검사해 완료된 작업을 찾습니다.

일반 파일·블록 장치에서 O_NONBLOCK이 I/O 대기 방지를 보장하지는 않습니다. 비동기 완료 확인도 반드시 콜백일 필요는 없습니다. 잘못된 인자나 제출 실패는 각 API의 오류 반환으로 별도 확인합니다.

이 예의 조건

  • 본문과 전체 도표의 예를 따라 요청 제출부터 결과 회수까지 진행합니다.
관계도의 보충 설명
전체 도표의 표기·관계·조건
구분전체 도표의 내용
제목I/O 요청과 완료 대기는 다른 호출일 수 있다
관계 설명제출에 성공한 뒤 아직 처리 중인 요청 목록의 완료로 aio_suspend가 정상 반환하는 선택 경로다. 시간은 아래로 흐른다. 제출 성공 반환은 비동기 작업의 완료가 아니다. 이미 완료되면 대기는 즉시 반환할 수 있고 시그널·timeout에 의한 다른 반환과 제출 오류는 기존 지원 패널에 남긴다. 목록 중 완료된 요청을 aio_error로 확인한 뒤 그 요청의 aio_return을 한 번 호출해 결과를 회수한다. 점선 화살표는 호출의 반환이며 완료 callback을 뜻하지 않는다.
설명·조건일반 파일·블록 장치에서 O_NONBLOCK이 I/O 대기 방지를 보장하지는 않습니다. 비동기 완료 확인도 반드시 콜백일 필요는 없습니다. 잘못된 인자나 제출 실패는 각 API의 오류 반환으로 별도로 확인합니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
요청 제출

비동기 읽기를 큐에 제출한다.

  • I/O 요청·결과 1: aio_read 제출 성공
  • I/O 요청·결과 2: 작업 미완료
  • 호출 스레드가 다른 작업 가능
별도 대기

아직 완료되지 않은 요청을 기다린다.

  • I/O 요청·결과 1: aio_suspend 호출
  • 목록 내 완료 또는 별도 반환 조건 대기
완료 확인

정상 완료로 대기 호출이 반환된 경로이다.

  • I/O 요청·결과 1: aio_error로 완료 요청 식별
  • 완료 상태 검사
결과 회수

확인한 완료 요청의 결과를 한 번 얻는다.

  • I/O 요청·결과 1: aio_return 1회
  • 바이트 수/오류 회수; callback 의미 아님

I/O 요청과 완료 대기는 다른 호출일 수 있다

비동기 읽기를 큐에 제출한다. 확인한 완료 요청의 결과를 한 번 얻는다. 각 단계의 값과 전환 이유를 비교합니다.

I/O 인터페이스 · 요청과 완료 대기

비동기 요청을 제출한 뒤에도 완료를 기다리는 호출을 사용할 수 있습니다

블로킹 여부는 개별 호출이 기다리는지를 봅니다. 비동기 I/O는 요청과 완료 확인을 나누며, 논블로킹 read도 성공하면 데이터를 그 호출에서 돌려줍니다.

비동기 요청과 별도 완료 대기 호출제출에 성공한 뒤 아직 처리 중인 요청 목록의 완료로 aio_suspend가 정상 반환하는 선택 경로다. 시간은 아래로 흐른다. 제출 성공 반환은 비동기 작업의 완료가 아니다. 이미 완료되면 대기는 즉시 반환할 수 있고 시그널·timeout에 의한 다른 반환과 제출 오류는 기존 지원 패널에 남긴다. 목록 중 완료된 요청을 aio_error로 확인한 뒤 그 요청의 aio_return을 한 번 회수한다. 점선 화살표는 호출의 반환이며 완료 callback을 뜻하지 않는다.호출 스레드비동기 요청aio_read 제출제출 성공 반환다른 작업 가능aio_suspend 대기목록 내 요청 완료상태 확인 후 aio_return 1회
비동기 요청과 별도 완료 대기 호출제출에 성공한 뒤 아직 처리 중인 요청 목록의 완료로 aio_suspend가 정상 반환하는 선택 경로다. 시간은 아래로 흐른다. 제출 성공 반환은 비동기 작업의 완료가 아니다. 이미 완료되면 대기는 즉시 반환할 수 있고 시그널·timeout에 의한 다른 반환과 제출 오류는 기존 지원 패널에 남긴다. 목록 중 완료된 요청을 aio_error로 확인한 뒤 그 요청의 aio_return을 한 번 회수한다. 점선 화살표는 호출의 반환이며 완료 callback을 뜻하지 않는다.호출스레드비동기요청aio_read 제출제출 성공 반환다른 작업 가능aio_suspend대기목록 내 요청완료상태 확인 후aio_return 1회

블로킹 read · 결과를 반환

데이터·EOF·오류 등을 반환할 수 있을 때까지 기다릴 수 있습니다.

확인: 읽은 바이트 수 또는 오류. 요청한 길이보다 적게 읽을 수도 있습니다.

논블로킹 read · 지금 가능한 결과

논블로킹이 지원되는 대상에서는 데이터 대기 없이 반환합니다. 데이터가 없으면 EAGAIN 등의 오류를 확인합니다.

확인: 바이트 수·EOF·오류. 준비 상태를 기다린 뒤 재시도할 수 있습니다.

aio_read · 요청을 제출

비동기 읽기를 큐에 제출합니다. 제출 성공은 읽기 완료나 성공을 뜻하지 않습니다.

확인: aio_error로 상태를 보고, 완료 뒤 aio_return으로 결과를 한 번 회수합니다.

aio_suspend · 별도 호출에서 대기

대상 비동기 요청 중 하나의 완료·시그널·시간 제한까지 기다릴 수 있습니다. 이미 완료된 요청이 있으면 바로 반환합니다.

확인: 각 요청의 상태를 검사해 완료된 작업을 찾습니다.

일반 파일·블록 장치에서 O_NONBLOCK이 I/O 대기 방지를 보장하지는 않습니다. 비동기 완료 확인도 반드시 콜백일 필요는 없습니다. 잘못된 인자나 제출 실패는 각 API의 오류 반환으로 별도 확인합니다.

이 예의 조건

  • 본문과 전체 도표의 예를 따라 요청 제출부터 결과 회수까지 진행합니다.

관계도의 보충 설명

전체 도표의 표기·관계·조건
구분전체 도표의 내용
제목I/O 요청과 완료 대기는 다른 호출일 수 있다
관계 설명제출에 성공한 뒤 아직 처리 중인 요청 목록의 완료로 aio_suspend가 정상 반환하는 선택 경로다. 시간은 아래로 흐른다. 제출 성공 반환은 비동기 작업의 완료가 아니다. 이미 완료되면 대기는 즉시 반환할 수 있고 시그널·timeout에 의한 다른 반환과 제출 오류는 기존 지원 패널에 남긴다. 목록 중 완료된 요청을 aio_error로 확인한 뒤 그 요청의 aio_return을 한 번 호출해 결과를 회수한다. 점선 화살표는 호출의 반환이며 완료 callback을 뜻하지 않는다.
설명·조건일반 파일·블록 장치에서 O_NONBLOCK이 I/O 대기 방지를 보장하지는 않습니다. 비동기 완료 확인도 반드시 콜백일 필요는 없습니다. 잘못된 인자나 제출 실패는 각 API의 오류 반환으로 별도로 확인합니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
요청 제출

비동기 읽기를 큐에 제출한다.

  • I/O 요청·결과 1: aio_read 제출 성공
  • I/O 요청·결과 2: 작업 미완료
  • 호출 스레드가 다른 작업 가능
별도 대기

아직 완료되지 않은 요청을 기다린다.

  • I/O 요청·결과 1: aio_suspend 호출
  • 목록 내 완료 또는 별도 반환 조건 대기
완료 확인

정상 완료로 대기 호출이 반환된 경로이다.

  • I/O 요청·결과 1: aio_error로 완료 요청 식별
  • 완료 상태 검사
결과 회수

확인한 완료 요청의 결과를 한 번 얻는다.

  • I/O 요청·결과 1: aio_return 1회
  • 바이트 수/오류 회수; callback 의미 아님

블로킹과 논블로킹은 호출 하나가 결과를 기다리느냐의 문제이고, 동기와 비동기는 요청과 완료가 같은 호출 안에서 끝나느냐의 문제입니다. 보통의 read()는 데이터가 올 때까지 기다리는 동기 블로킹 호출이고, O_NONBLOCK을 켠 read()는 데이터가 없으면 EAGAIN으로 바로 돌아오는 동기 논블로킹 호출입니다. 그림의 aio_read()나 io_uring은 요청을 제출하는 호출과 완료를 확인하는 호출이 따로 있는 비동기 방식입니다(11장 2편).

캐시를 잘 쓰는 코드란 무엇인가

CPU는 메모리를 보통 64바이트의 캐시 라인 단위로 가져오므로, 이웃한 주소를 차례로 읽는 코드는 한 번 가져온 라인을 여러 번 씁니다. C의 2차원 배열은 행을 먼저 늘어놓으므로, 행을 따라 읽으면 연속된 주소를, 열을 따라 읽으면 한 행만큼 떨어진 주소를 읽습니다. 다음 코드는 64바이트 라인 128개를 담는 작은 LRU 캐시로 두 순회의 캐시 미스를 셉니다.

from collections import OrderedDict

N, INT, LINE, CACHE_LINES = 256, 4, 64, 128   # 256×256 int 행렬, 64바이트 캐시 라인, 8KiB 캐시

def misses(order):
    cache, count = OrderedDict(), 0
    for i, j in order:
        line = (i * N + j) * INT // LINE      # 행 우선 배치에서 이 원소가 든 캐시 라인
        if line in cache:
            cache.move_to_end(line)
        else:
            count += 1
            cache[line] = True
            if len(cache) > CACHE_LINES:
                cache.popitem(last=False)     # 가장 오래 쓰지 않은 라인을 내보낸다(LRU)
    return count

rows = ((i, j) for i in range(N) for j in range(N))
cols = ((i, j) for j in range(N) for i in range(N))
print("행 우선 순회의 캐시 미스:", misses(rows))
print("열 우선 순회의 캐시 미스:", misses(cols))
행 우선 순회의 캐시 미스: 4096
열 우선 순회의 캐시 미스: 65536

행을 따라 읽으면 라인 하나로 원소 16개를 읽으므로 원소 65,536개에 미스가 4,096번이지만, 열을 따라 읽으면 같은 라인을 다시 쓰기 전에 캐시에서 밀려나 원소마다 미스가 납니다. 실제 CPU는 캐시가 여러 단계이고 미리 읽어 오는 장치도 있어 차이가 이만큼 극단적이지는 않지만, 큰 행렬에서 순회 순서 하나로 실행 시간이 몇 배씩 달라지는 이유가 이것입니다.

제로 카피와 epoll은 무엇을 줄이나

제로 카피는 파일을 네트워크로 보낼 때 데이터가 사용자 공간을 거치지 않게 해, 복사와 시스템 콜을 줄입니다(11장 3편). select()는 호출할 때마다 감시할 fd 전체를 커널에 넘기고 돌아온 뒤 전체를 다시 훑어야 하지만, epoll은 관심 있는 fd를 한 번 등록해 두고 준비된 것의 목록만 받으므로, 연결이 많고 그중 일부만 바쁠 때 훨씬 효율적입니다. macOS와 BSD에는 비슷한 kqueue가 있고, Windows의 IOCP는 준비되었다는 알림 대신 I/O가 끝났다는 알림을 주는 방식입니다(11장 2편).

정리

  • 튜닝은 병목을 측정하고, 설정 하나를 바꾸고, 같은 부하에서 비교하고, 되돌릴 수 있게 기록하는 일의 반복입니다.
  • fd 한도는 프로세스를 시작하는 방식에 따라 적용되는 곳이 다르며, 올리기 전에 fd가 새고 있지 않은지 확인합니다.
  • TCP 파라미터는 대기열, 연결을 맺는 중인 요청, TIME_WAIT 재사용, 포트 범위처럼 서로 다른 대상을 다루며, 기본값과 문서의 경고를 확인한 뒤 바꿉니다.
  • swappiness는 스왑과 파일 페이지 회수의 상대 비용이며, 0이어도 스왑을 완전히 끄지는 않습니다.
  • 면접 질문은 각 개념이 왜 필요하고 무엇과 맞바꾸는지를 설명할 수 있으면 답할 수 있습니다.

연습 문제

1. fd 한도와 페이지 낭비 계산하기

0번부터 빈틈없이 fd를 쓰는 프로세스가 기본 파일 12개와 소켓 120개를 열어 두었고, 소켓 두 개를 더 열어야 합니다. soft 한도가 133이면 몇 개까지 더 열 수 있나요? 실패하지 않으려면 한도가 최소 얼마여야 하나요? 또 4KiB 페이지 단위로만 메모리를 받는다면, 4,097바이트짜리 영역과 1바이트짜리 영역을 따로 받을 때 낭비되는 공간은 모두 몇 바이트인가요?

해설 보기

지금 0번부터 131번까지 132개를 쓰고 있으므로 다음 fd는 132번입니다. 한도 133은 번호가 133보다 작은 fd만 허락하므로 132번은 열 수 있지만, 그다음 133번은 EMFILE로 실패합니다. 두 개를 모두 열려면 133번까지 허락하는 한도 134가 필요합니다. 메모리는 4,097바이트 영역이 페이지 두 개, 1바이트 영역이 페이지 하나를 받으므로, 낭비는 (8,192 - 4,097) + (4,096 - 1) = 4,095 + 4,095 = 8,190바이트입니다. 이 낭비가 페이징의 내부 단편화이며, 작은 객체를 많이 할당하는 프로그램은 그래서 페이지를 잘게 나누어 쓰는 할당기를 씁니다.

2. 포트가 바닥나는 클라이언트

어떤 서비스가 같은 데이터베이스 서버의 같은 포트로 요청마다 새 TCP 연결을 맺고 바로 끊습니다. 연결을 먼저 끊는 쪽은 클라이언트이고, Linux에서 TIME_WAIT 상태는 60초 동안 유지됩니다. 기본 포트 범위로 이 클라이언트가 초당 몇 개 정도의 새 연결을 계속 맺을 수 있나요? 해결 방법도 제시하세요.

해설 보기

기본 포트 범위 32768부터 60999까지는 28,232개입니다. 연결을 먼저 끊은 클라이언트 쪽 소켓이 60초 동안 TIME_WAIT로 남아 같은 목적지에 대해 그 포트를 다시 쓸 수 없다면, 28,232 ÷ 60으로 초당 470개 정도가 한계이고, 이를 넘으면 connect()가 EADDRNOTAVAIL로 실패합니다. 가장 좋은 해결은 연결 풀을 써서 연결을 재사용하는 것입니다. 그 밖에 포트 범위를 넓히거나, 문서의 경고를 이해한 뒤 tcp_tw_reuse로 안전한 경우의 재사용을 허락하는 방법이 있지만, 이는 증상을 늦출 뿐입니다.

3. 네 가지 I/O 호출 구분하기

다음 네 가지를 동기와 비동기, 블로킹과 논블로킹으로 분류하세요. 첫째, 보통의 소켓에서 recv(). 둘째, O_NONBLOCK을 켠 소켓에서 recv(). 셋째, epoll_wait()로 준비된 소켓을 기다린 뒤 recv(). 넷째, io_uring으로 읽기를 제출하고 나중에 완료 큐를 확인하는 것.

해설 보기

첫째는 데이터가 올 때까지 호출이 돌아오지 않는 동기 블로킹 호출입니다. 둘째는 데이터가 없으면 EAGAIN으로 바로 돌아오는 동기 논블로킹 호출로, 데이터를 받는 일 자체는 그 호출 안에서 끝나므로 동기입니다. 셋째는 epoll_wait()가 여러 소켓 가운데 준비된 것이 생길 때까지 기다리는 블로킹 호출이고, 그 뒤의 recv()는 준비된 데이터를 읽는 동기 호출이므로, 다중화한 동기 I/O입니다. 넷째는 요청을 제출하는 호출과 결과를 확인하는 일이 나뉘어 있고, 커널이 그 사이에 읽기를 끝내 두므로 비동기 I/O입니다.

다음 장의 분산·다중처리와 부분 장애에서는 여러 CPU와 여러 컴퓨터가 함께 일할 때 생기는 문제, 특히 일부만 고장 나는 부분 장애와 그 아래에서 안전하게 다시 시도하는 방법을 살펴봅니다.