[C][소켓] socket read 중 close() 시 안전하게 구현하기
2026. 3. 25. 00:58이전글
[C][소켓] 메인스레드가 recv 하는 동안 fd가 close되면 어떻게 될까?
결론 => recv 함수의 리턴값은 0, errno은 0이 반환하게 된다. recv 함수 반환값에 대한 처리에 따라 달려있다. 하지만 0을 반환하는 건 Undefined behavior이므로 반드시 shutdown 호출 후 스레드가 빠져나온
i5i5.tistory.com
자, 이제 아래의 상황일 때, 안전하게 구현하는 방법을 생각해보자.
스레드 A: socket read 함수로 block
스레드 B: stop()함수가 불리우면 (혹은 소멸자가) read를 중단하고 스레드 A가 종료되는 걸 기다린 후 스레드 B 종료
스레드 A가 read()에서 블로킹되어 있을 때, 스레드 B가 안전하게 이를 중단시키고 자원을 안전하게 정리해야한다.
핵심은 shutdown()을 통해 블로킹을 해제하고, 원자적 플래그(Atomic Flag)를 통해 상태를 공유하며, join()으로 동기화를 맞추는 것이다.
1. 처리 로직 순서
- 상태 변경: 스레드 B에서 종료 플래그(keep_running)를 false로 설정한다.
- 강제 해제: 스레드 B에서 shutdown(fd, SHUT_RDWR)를 호출한다. 이때 스레드 A의 read()는 즉시 0을 반환하며 깨어난다.
- 스레드 대기: 스레드 B는 pthread_join 또는 thread.join()을 호출하여 스레드 A가 완전히 종료될 때까지 기다린다.
- 자원 회수: 스레드 A가 종료된 것이 확인되면, 스레드 B에서 최종적으로 close(fd)를 호출한다.
2. C 코드 구현
#include <stdio.h>
#include <unistd.h>
#include <pthread.h>
#include <sys/socket.h>
#include <stdatomic.h>
atomic_bool keep_running = true;
int sock_fd;
void* thread_a_func(void* arg) {
char buf[1024];
while (atomic_load(&keep_running)) {
ssize_t n = read(sock_fd, buf, sizeof(buf));
if (n <= 0) {
// shutdown 호출 시 n은 0이 되고 루프를 빠져나감
printf("Thread A: Read returned %ld, exiting...\n", n);
break;
}
// 데이터 처리 로직
}
return NULL;
}
void thread_b_cleanup(pthread_t target_thread) {
// 1. 플래그 변경
atomic_store(&keep_running, false);
// 2. shutdown으로 read 블로킹 해제
shutdown(sock_fd, SHUT_RDWR);
printf("Thread B: Shutdown called.\n");
// 3. 스레드 종료 대기
pthread_join(target_thread, NULL);
printf("Thread B: Thread A joined.\n");
// 4. 최종 자원 해제 (FD Reuse 방지)
close(sock_fd);
}
3. C++ 코드 구현 (Modern C++)
#include <iostream>
#include <thread>
#include <atomic>
#include <vector>
#include <unistd.h>
#include <sys/socket.h>
class SocketManager {
private:
std::atomic<bool> keep_running{true};
int sock_fd;
std::thread reader_thread;
public:
SocketManager(int fd) : sock_fd(fd) {
reader_thread = std::thread(&SocketManager::readLoop, this);
}
void readLoop() {
char buf[1024];
while (keep_running.load()) {
int n = read(sock_fd, buf, sizeof(buf));
if (n <= 0) {
std::cout << "Reader: Stopped by shutdown or remote close.\n";
break;
}
// 데이터 처리
}
}
void stop() {
// 1. 상태 변경
keep_running = false;
// 2. 통신 채널 차단 (read 블로킹 해제)
shutdown(sock_fd, SHUT_RDWR);
// 3. 스레드 합류
if (reader_thread.joinable()) {
reader_thread.join();
}
// 4. 리소스 반환
close(sock_fd);
std::cout << "Manager: Cleanup complete.\n";
}
};
4. 구현 시 주의사항
- Atomic 사용: keep_running 플래그는 반드시 atomic 타입을 사용해야 한다. 일반 bool 사용 시 컴파일러 최적화(레지스터 캐싱)로 인해 스레드 A가 플래그 변경을 영원히 인지하지 못할 수 있다.
- 순서의 중요성: close()를 join()보다 먼저 하면 안 된다. close()가 호출되는 순간 해당 fd 번호가 즉시 OS에 반납되어, 스레드 A가 종료되기 직전 다른 리소스가 그 번호를 할당받는 사고(Race Condition)가 발생할 수 있다.
- 에러 처리: read()가 -1을 반환할 경우 errno를 체크하여 EINTR(인터럽트) 등의 상황에 대한 예외 처리를 추가하는 것이 견고한 코드를 만드는 길이다.
요약하면, 스레드 B에서 "플래그 변경 → shutdown → join → close" 순서를 지키는 것이 가장 안전한 표준 패턴이다.
고찰 1. 만약 shutdown(sock_fd, SHUT_RDWR);가 없다면?
만약 stop() 함수에서 shutdown() 호출을 생략한다면, 시스템은 '데드락(Deadlock)' 또는 '무한 대기' 상태에 빠질 위험이 매우 커진다. SocketManager::stop() 코드에서 shutdown() 호출하지 않는다면, 프로그램은 논리적으로 완결되지 못하고 멈춰버릴 가능성이 높다.
1. 스레드 A의 무한 블로킹 (Infinite Blocking)
스레드 A(readLoop)는 read() 함수를 호출한 상태에서 커널의 응답을 기다리며 블로킹(Blocking) 되어 있다.
- 문제: read()는 소켓에 데이터가 들어오거나, 연결이 끊기기 전까지는 절대로 제어권을 반환하지 않는다.
- 결과: 스레드 B가 is_stopped 플래그를 true로 바꿔도, 스레드 A는 루프의 조건문(while)으로 돌아가 이 플래그를 확인할 기회조차 얻지 못한다. 즉, 스레드 A는 read()에 갇혀 영원히 종료되지 않는다.
스레드 B는 stop()을 호출하면서 reader_thread.join()을 수행한다.
- 문제: join()은 대상 스레드가 종료될 때까지 호출한 스레드를 멈추게 한다.
- 결과: 스레드 A는 read()에서 빠져나오지 못하고, 스레드 B는 그런 스레드 A가 끝나기만을 기다리게 된다. 결국 두 스레드 모두 아무것도 하지 못하는 정지 상태에 빠지며, 프로그램이 응답 없음(Hang) 상태가 된다.
join()에서 멈춰버렸기 때문에, 그 뒤에 위치한 close(sock_fd) 코드는 영원히 실행되지 않는다.
- 리소스 누수: 파일 디스크립터가 반환되지 않고 프로세스가 종료될 때까지 남게 된다.
- 소멸자 문제: 만약 이 과정이 소멸자에서 일어난다면, 객체를 파괴하려던 스레드(예: 메인 스레드)가 소멸자에서 멈춰버려 프로그램 전체가 종료되지 않는 현상이 발생한다.
여기서 shutdown()은 일종의 "강제 깨우기 신호" 역할을 한다.
- shutdown(sock_fd, SHUT_RDWR)이 호출되면 커널은 즉시 해당 소켓의 통신을 중단시킨다.
- 이 신호를 받은 커널은 read()를 수행 중이던 스레드 A에게 **"더 이상 읽을 데이터가 없으니 돌아가라"**고 명령하며 0을 반환시킨다.
- 비로소 스레드 A는 read()에서 빠져나와 is_stopped 플래그를 확인하거나 루프를 종료할 수 있게 된다.
- 스레드 A가 종료되면 스레드 B의 join()이 해제되고, 안전하게 close()까지 도달한다.
요약하면, shutdown은 반드시 필요하다.
고찰 2. 소멸자에서 스레드 A를 정리하는 로직이 들어가는 경우
C++에서 SocketManager와 같이 내부적으로 스레드와 파일 디스크립터(fd)를 관리하는 객체는 소멸자(~SocketManager)에서 자원을 확실히 정리해 주어야 한다. 이를 통해 객체가 스코프를 벗어날 때 발생할 수 있는 자원 누수와 좀비 스레드 문제를 원천 봉쇄할 수 있다.
1. 소멸자에서 fd같은 자원을 정리해야하는 이유
C++의 핵심 철학은 '자원의 수명은 객체의 수명과 일치해야 한다'는 것이다.
- 안전한 종료 보장: 사용자가 실수로 stop()을 명시적으로 호출하지 않더라도, 객체가 파괴될 때 자동으로 통신이 끊기고 스레드가 조인(Join)된다.
- 예외 안전성: 코드 실행 중 예외가 발생하여 스택 풀기(Stack Unwinding)가 일어나더라도, 소멸자가 호출되면서 자원이 안전하게 정리된다.
2. 소멸자 설계 시 주의할 점
사용자가 stop()을 직접 호출한 후 객체가 소멸될 때 다시 stop()이 호출될 수 있다. 따라서 stop() 함수는 여러 번 호출해도 문제가 없도록(Idempotent) 설계해야 한다.
- 스레드 조인(Join)의 위치
스레드는 클래스의 다른 멤버 변수(특히 소켓 fd나 데이터 버퍼)를 참조하며 돌고 있을 가능성이 크다. 따라서 멤버 변수들이 파괴되기 전에 스레드가 먼저 종료(Join)되어야 한다. C++에서 멤버 변수는 선언된 역순으로 파괴되므로, std::thread 객체는 클래스의 하단에 선언하는 것이 유리하다.
- 소멸자에서의 예외 발생 금지
C++ 소멸자 내에서 예외가 밖으로 나가면 프로그램이 즉시 종료(std::terminate)될 수 있다. stop() 내부의 close()나 shutdown()은 실패하더라도 예외를 던지기보다는 로그를 남기는 정도로 처리해야 한다.class SocketManager {
private:
std::atomic<bool> is_stopped{false};
int sock_fd;
std::thread reader_thread;
public:
SocketManager(int fd) : sock_fd(fd) {
reader_thread = std::thread(&SocketManager::readLoop, this);
}
// 소멸자: 여기서 stop()을 호출하여 자원 정리를 보장한다.
~SocketManager() {
stop();
}
void stop() {
// 이미 중단되었다면 아무것도 하지 않는다 (중복 호출 방지)
if (is_stopped.exchange(true)) {
return;
}
// 1. 통신 차단 (read 블로킹 해제)
if (sock_fd >= 0) {
shutdown(sock_fd, SHUT_RDWR);
}
// 2. 스레드 종료 대기
if (reader_thread.joinable()) {
reader_thread.join();
}
// 3. 파일 디스크립터 최종 해제
if (sock_fd >= 0) {
close(sock_fd);
sock_fd = -1; // 중복 close 방지
}
}
void readLoop() {
// is_stopped 플래그를 체크하며 루프 수행
while (!is_stopped.load()) {
// read logic...
}
}
};
'C > C' 카테고리의 다른 글
| [C][소켓] 메인스레드가 recv 하는 동안 fd가 close되면 어떻게 될까? (0) | 2024.09.04 |
|---|---|
| [C/C++] __attribute__((packed))에 대한 이해 (1) | 2024.03.28 |
| [C] 파일입출력 예제 (0) | 2023.03.31 |
| [C/C++] 실행시간 측정하기 time(), clock() (0) | 2023.03.30 |
| [C] C언어 알아두기 (Main) (0) | 2022.11.16 |