OS는 사용자나 시스템 수준에서 생성할 수 있는 파일 디스크립터 개수를 제한
- 프로세스는 제한된 개수의 파일 디스크립터를 초과 생성 불가능
- 파일 디스크립터 개수 제한이 1024라면, 1024개 이상의 클라이언트가 연결 시도 시 Too Many Open Files 오류 메시지 및 생성 실패
파일 디스크립터가 가리킬 수 있는 대상
- 일반 파일
- 디렉토리
- 소켓 ← HTTP 연결, DB 연결 등
- 파이프
- 디바이스 파일
- 심볼릭 링크
Q. 파일 디스크립터란?
A. 운영체제가 열린 파일이나 I/O 리소스를 식별하기 위해 부여하는 정수 값. 같은 프로세스 내 모든 스레드가 파일 디스크립터 테이블을 공유.
Q. 파일 디스크립터는 누가 관리?
A. OS 커널이 프로세스별로 파일 디스크립터 테이블 관리. 각 프로세스는 독립적인 파일 디스크립터 공간을 가짐.
Q. 근데 왜 ‘File’ 디스크립터임? 소켓 연결이 왜 파일이야..
A. 유닉스 설계 원칙 및 철학때문에… == Everything is a file
- Unix 계열 OS에서는 모든 I/O 리소스를 파일처럼 다룸
- 실제 파일이 아니어도 파일처럼 읽고 쓸 수 있으면 파일 디스크립터 사용
Q. 디렉토리(예시)를 어떻게 파일 디스크립터로 읽는거지?
A. OS는 일관된 인터페이스로 모든 리소스를 다루기 위해 FD를 사용 (유닉스 철학땜에;;)
- 디렉토리도 읽는 대상이므로 FD로 추상화
- 일관된 read() 인터페이스 사용 가능
// Java에서 디렉토리 읽기
Path dir = Paths.get("/home/user");
try (DirectoryStream<Path> stream = Files.newDirectoryStream(dir)) { // FD 할당
for (Path entry : stream) {
System.out.println(entry.getFileName());
}
} // FD 해제
// 리눅스 시스템 콜
int fd = open("/home/user", O_RDONLY | O_DIRECTORY); // FD 할당
struct dirent *entry;
while ((entry = readdir(fd)) != NULL) { // FD로 읽기
printf("%s\\n", entry->d_name);
}
close(fd); // FD 해제
FD 자문자답
Q1. Socket이란
Socket = 파이프
HTTP 요청 = 파이프를 통해 흐르는 물
Socket은 HTTP 요청이 아니라 "양방향 통신 파이프"이고, HTTP는 그 파이프 위에서 흐르는 "요청-응답 형식의 메시지"이며, WebSocket은 HTTP와는 다른 "지속 연결 기반 양방향 프로토콜"
Q2.Thread와 FD의 관계, FD와 프로세스의 관계
- 프로세스마다 독립적인 FD Table을 가지며, 같은 프로세스 내 모든 Thread는 이 FD Table을 공유함
- 각 FD는 실제 커널 객체를 가리키는 포인터
- Thread는 독립적인 Stack과 CPU 레지스터만 가짐
- Heap, FD Table, Code/Data Segment는 모두 공유
- 한 Thread가 close(3)을 호출하면
// Thread 1
close(3);
// Thread 2 (동시 실행 중)
write(3, "data", 4); // Bad file descriptor 에러
Q3. 각 프로세스는 FD 테이블을 갖는건가?
- yes. 각 프로세스는 개별 FD 테이블을 가지고, 프로세스 내 스레드들은 FD 테이블을 공유함
Q4. FD가 없으면 스레드는 작업을 못하는거야?
- 아예 못하는건 아님.
- CPU 연산 작업은 할 수 있지만, 커널 외부(디스크, 네트워크, 디바이스)와 통신하려면 반드시 FD가 필요함.
- → 이는 Unix의 "Everything is a file" 철학 때문에 커널이 모든 I/O 자원을 파일로 추상화했기 때문
Q5. FD와 트래픽 초과도 관련이 있나?
- 관련있음. FD 개수 제한 때문에 동시 연결 수가 제한되므로, 트래픽이 폭증하면 FD가 부족해져서 새로운 연결을 받을 수 없게 되어 서비스 장애가 발생
- 시스템 콜 레벨 : Too many open files Error
- 애플리케이션 레벨 : SocketException
Q5. FD 제한 vs. Thread Pool
- FD 제한 = 동시 연결 수 제한
- Thread Pool = 동시 처리 수 제한
FD limit: 10000
Thread Pool: 200
상황:
동시 접속 5000명 → FD 5000개 사용
하지만 Thread 200개로만 처리
→ 4800명은 큐에서 대기
Q6. FD는 프로세스 소유인데 스레드 단위로 FD를 사용하는 것처럼 말해도 되는 이유
- Thread는 FD를 소유하지 않고 단지 FD 번호를 함수 인자로 전달받아 시스템 콜을 호출할 뿐
- 커널은 어떤 프로세스의 어떤 FD 번호인지만 확인하고 실제 작업을 수행
Process (PID: 1234)
├─ FD Table
│ ├─ fd 3: socket
│ └─ fd 4: file
├─ Thread 1
│ └─ Stack
│ └─ int fd = 3;
└─ Thread 2
└─ Stack
└─ int fd = 4;
- 모든 Thread가 같은 FD를 동시에 사용 가능
- 시스템 콜 시 <PID + FD 번호>로 커널이 실제 객체 찾음
- 즉, 커널이 어떤 프로세스의 FD 테이블인지 판단
- 이 점 때문에 같은 프로세스 내 모든 Thread가 같은 FD 테이블을 공유하는 거임
Q7. 멀티스레드 서버에서 여러 스레드가 하나의 socket FD를 공유해도 되는 이유
- 멀티스레드 서버에서 여러 Thread가 Server Socket FD(listen socket)는 공유 OK
- 여러 스레드가 동시에 accept()를 호출해도 운영체제(커널)가 한 번에 하나의 스레드에게만 연결을 전달하도록 동기화를 보장
- 각 스레드는 accept()의 결과물로 서로 다른 새로운 Client FD를 받음
- 근데 Client Socket FD(connected socket)는 Read Condition 때문에 공유 X
- 왜냐하면 accept()가 각 Thread에게 독립적인 새 FD를 반환하기 때문
- FD close 문제, 프로토콜 파괴, 데이터 혼선
Q8. FD과 멀티스레드
- 메인 스레드에서 서버 소켓은 LISTEN 상태를 유지하며 접속 요청을 수신함
- 연결마다 생성된 새로운 FD를 Worker 스레드가 클라이언트 소켓(FD)을 부여받아 전담 관리
- How Tomcat Handles HTTP Requests: A Deep Dive into Threads and I/O
[Tomcat NIO Connector]
Acceptor Thread (1개)
├─ serverSocket.accept() → fd=100
│ └─→ Worker Thread 1 (fd=100 전담)
│
├─ serverSocket.accept() → fd=101
│ └─→ Worker Thread 2 (fd=101 전담)
│
└─ serverSocket.accept() → fd=102
└─→ Worker Thread 3 (fd=102 전담)
→ 각 Worker Thread가 독립적인 Client FD 소유
→ Server Socket은 Acceptor만 사용
// Apache Tomcat Acceptor Thread (단 1개)
while (true) {
Socket clientSocket = serverSocket.accept(); // 새 FD 할당
executor.submit(new RequestHandler(clientSocket)); // Worker Thread에 전달
}
// Worker Thread (200개 Pool)
class RequestHandler implements Runnable {
private Socket socket; // 이 Thread 전용 FD
public void run() {
InputStream in = socket.getInputStream();
OutputStream out = socket.getOutputStream();
// HTTP 요청 파싱
String request = readRequest(in);
// 비즈니스 로직
String response = processRequest(request);
// HTTP 응답 전송
out.write(response.getBytes());
socket.close(); // FD 해제
}
}
Q9. 컨텍스트 스위칭 일어날 때 FD 정보도 같이 바뀌나? 뭐가 스위치 되냐에 따라 다른가?
스레드 간 컨스
- 바뀌는 것
- CPU 레지스터 상태 - 현재 계산하던 값들
- 스택 포인터 (Stack Pointer) - 어디까지 일을 처리했는지 기록한 메모리 주소가 바뀜
- Program Counter - 다음 코드 어디 실행할 차례인지 가리키는 주소
- 안 바뀌는 것
- FD Table
- 가상 메모리 주소 공간 (Code, Data, Heap)
프로세스 간 컨스
- 바뀌는 것
- 위에 언급된 모든 것
- 메모리 맵
- FD Table 포인터
- 결과
- 카카오톡의 4번 FD는 '채팅 로그 파일'일 수 있지만, 크롬의 4번 FD는 '웹 서버 소켓'일 수 있음
- OS는 프로세스가 바뀔 때마다 "이제부터 이 FD Table을 봐라" 하고 포인터를 통째로 바꿔버림
Q11. FD limit에 걸리면 에러는 어디서 캐치?
- 먼저 에러를 감지하는 것은 커널
- 커널이 새 FD 번호 할당을 거부하고 시스템 콜 결과로 EMFILE
- 에러 전달 경로 : OS → JVM → Spring
- OS 커널이 EMFILE을 던지면, JVM이 이를 SocketException으로 바꾸고, 톰캣이 로그에 기록
Q12. 스레드가 부족한 상황 vs. FD가 부족한 상황
- Thread 부족 == 연결은 성공, 처리만 지연
- FD 부족 == 연결 자체 실패
- 스레드는 남아도는데 Too many open files가 뜨는 상황이라면 → 십중팔구 소켓을 닫지 않아 발생한 FD 누수 때문임
Q13. CLOSE_WAIT, TIME_WAIT 상태는 FD를 잡고 있는 상태?
- CLOSE_WAIT과 TIME_WAIT 모두 FD를 점유하는 상태
- CLOSE_WAIT
- 애플리케이션이 close()를 호출하지 않아 무한정 FD를 점유하는 위험한 상태
- 애플리케이션 책임
- 코드상의 문제
- TIME_WAIT
- 커널이 자동으로 관리하며 60초 후 자동 해제되는 정상적인 상태
- 커널 책임
Q14. HTTP keep-alive랑 WebSocket은 OS 입장에서 보면 뭐가 다를까?
- OS 커널 입장에서 HTTP Keep-Alive와 WebSocket 모두 동일한 TCP Socket(FD 1개)
- Keep-Alive
- 요청마다 read/write가 반복되는 요청-응답 패턴 → FD 일시적 점유 연장
- WebSocket
- 연결 후 양방향으로 언제든 read/write 가능한 지속 스트림 패턴 → FD 영구적 점유
- 애플리케이션 레벨 프로토콜만 다를 뿐 커널은 둘을 구분하지 못함
Q15. 동시 접속자가 10만 명이라면, 스레드도 10만 개를 만들어야 할까? (Netty 기반)
- No. I/O 멀티플렉싱(epoll/IOCP) 개념 도입하면 됨
- 1 Thread = 1 FD 모델은 이해하기 쉽지만, 접속자가 너무 많아지면 스레드 생성 비용과 컨텍스트 스위칭 오버헤드로 서버 터짐
- 워커 스레드가 FD 하나를 붙잡고 데이터가 올 때까지 기다리는게 아니라, 커널에게 이 1,000개의 FD 중에서 데이터 도착한 거 있으면 한꺼번에 알려달라고 부탁
혼자 파일디스크립터 딥다이브 했다가 운체에 대한 지식이 부족하다고 느껴져서 OSTEP 읽기 시작...