Linux Process와 Thread의 Kernel 관점 분석

· 약 7분 읽기 · 조회 5 #Linux#커널#프로세스#스레드#운영체제

교과서는 프로세스와 스레드를 다른 개념으로 설명하지만, Linux 커널 안에서는 둘 다 같은 구조체 하나(task_struct)로 표현되는 "태스크"다. 차이는 무엇을 공유하느냐뿐이다. 이 차이를 알면 top에 보이는 숫자, Java 스레드 수와 메모리, 컨텍스트 스위칭 비용, OOM 동작이 한 줄로 이어진다. 이 글은 커널 관점에서 프로세스와 스레드가 어떻게 만들어지고 무엇을 나눠 쓰는지 정리한 것이다.

큰 아파트 한 집에서 여러 사람이 부엌과 책장, 공구 벽을 함께 쓰면서 각자 자기 책상과 노트를 쓰고, 옆집은 따로 부엌과 선반을 가졌으며, 복도에서 회중시계를 든 사람이 다음 차례를 가리키는 단면도

커널에게는 모두 태스크다

커널의 스케줄러는 "프로세스"를 스케줄하지 않는다. 실행 가능한 태스크(task_struct)를 고른다. 태스크마다 다음을 가진다.

  • 자신만의 것: 태스크 ID(TID), 레지스터 상태, 커널 스택, 스케줄링 정보, 시그널 마스크
  • 포인터로 가리키는 것: 주소 공간(mm_struct), 열린 파일 표(files_struct), 파일 시스템 정보(fs_struct), 시그널 처리기(sighand_struct)

프로세스와 스레드의 차이는 이 포인터들이 다른 태스크와 같은 구조체를 가리키느냐다. 스레드는 주소 공간과 파일 표를 같은 프로세스의 다른 스레드와 공유하고, 새 프로세스는 자기 것을 따로 가진다.

프로세스 A의 스레드 세 개가 각자 태스크 구조체와 스택을 가지면서 하나의 주소 공간과 열린 파일 표를 공유하고, 프로세스 B는 자기 주소 공간과 파일 표를 따로 가지는 구조

만드는 방법은 clone 하나

프로세스와 스레드 모두 clone() 시스템 호출로 만든다. 무엇을 공유할지는 플래그로 정한다.

플래그 공유하는 것
CLONE_VM 주소 공간(메모리)
CLONE_FILES 열린 파일 디스크립터 표
CLONE_FS 현재 디렉터리, umask 등
CLONE_SIGHAND 시그널 처리기
CLONE_THREAD 같은 스레드 그룹으로 묶기(같은 PID로 보이게)
  • fork()는 공유 플래그 없이 복제한다. 메모리는 바로 복사하지 않고 쓰기 시 복사(Copy-on-Write) 로 페이지 표만 복제해 두었다가, 어느 쪽이 쓰는 순간 그 페이지만 복사한다.
  • pthread_create()(glibc의 NPTL)는 위 플래그를 모두 켜고 clone()을 부른다. 사용자 스레드 하나가 커널 태스크 하나에 대응하는 1:1 모델이다.

PID와 TID: top에 보이는 숫자의 정체

같은 프로세스의 스레드들은 하나의 스레드 그룹이다. 그룹의 ID(TGID)가 우리가 아는 PID이고, 각 스레드는 자기 TID를 가진다. 첫 스레드의 TID는 PID와 같다.

cat /proc/<pid>/status | grep Threads   # 이 프로세스의 스레드 수
ls /proc/<pid>/task                     # 스레드(TID) 목록
ps -eLf | grep <pid>                     # LWP 열이 TID
top -H -p <pid>                          # 스레드별 CPU 사용

getpid()는 TGID를, gettid()는 TID를 돌려준다. Java 스레드 덤프의 nid는 이 TID의 16진수다. top -H로 CPU를 많이 쓰는 TID를 찾고 16진수로 바꿔 스레드 덤프에서 찾으면 어떤 Java 스레드인지 알 수 있다.

공유하면 무엇이 달라지나

항목 같은 프로세스의 스레드끼리 다른 프로세스끼리
메모리 같은 주소 공간. 변수 공유, 동기화 필요 분리. 공유하려면 공유 메모리·파이프·소켓
장애 격리 한 스레드의 잘못된 메모리 접근이 프로세스 전체를 죽인다 한 프로세스가 죽어도 다른 프로세스는 산다
컨텍스트 스위칭 주소 공간을 바꿀 필요가 없어 가볍다 주소 공간 전환(페이지 표 교체)이 더해진다
생성 비용 작다 쓰기 시 복사 덕분에 생각보다 작지만 스레드보다 크다
파일 디스크립터 공유. 한 스레드가 닫으면 모두에게 닫힌다 분리(fork 직후에는 복제본)

메모리: 스레드마다 스택이 있다

스레드는 주소 공간을 공유하지만 스택은 각자 가진다. 스레드 수가 많으면 스택 예약 공간이 쌓인다.

  • C 프로그램의 기본 스레드 스택 크기는 보통 ulimit -s 값을 따른다.
  • Java 플랫폼 스레드의 스택은 -Xss로 정한다(64비트 Linux에서 흔히 1MB). 스레드 1,000개면 스택 예약만 약 1GB다. 실제로 쓰는 페이지만 물리 메모리를 차지하지만, 컨테이너 메모리 계산에는 넣어야 한다.
  • 스레드 수에는 커널 한도도 있다(/proc/sys/kernel/threads-max, cgroup의 pids.max). 컨테이너에서 "unable to create native thread"가 나면 메모리뿐 아니라 이 한도도 확인한다.

스케줄링: 스레드 단위로 경쟁한다

스케줄러는 태스크 단위로 CPU를 나눈다. 스레드가 100개인 프로세스는 스레드가 1개인 프로세스보다 CPU를 받을 기회가 많다. 그래서 컨테이너는 cgroup으로 CPU 사용량 한도를 그룹 단위로 건다.

  • 실행 대기 시간은 /proc/<pid>/task/<tid>/schedstat이나 pidstat -t -w(컨텍스트 스위치 수)로 본다.
  • 자발적 컨텍스트 스위치(I/O·락 대기)가 많으면 기다림이, 비자발적 스위치(시간 할당 소진)가 많으면 CPU 경쟁이 원인이다.

Java와 연결하기

  • 플랫폼 스레드 = 커널 태스크 하나(1:1). 스레드 풀 크기가 곧 커널 태스크 수다.
  • 가상 스레드(JDK 21): 많은 가상 스레드를 적은 수의 플랫폼 스레드(캐리어) 위에서 돌린다. 커널은 캐리어만 본다. 대기가 많은 I/O 작업에서 스레드 수와 스택 메모리 부담을 줄인다.
  • OOM: 커널 OOM killer는 스레드가 아니라 프로세스(주소 공간을 공유하는 스레드 그룹 전체)를 죽인다. JVM 힙 OOM(OutOfMemoryError)과 커널 OOM kill은 다른 사건이다. 컨테이너가 예고 없이 재시작됐다면 dmesg나 컨테이너 이벤트에서 OOM kill을 확인한다.

자주 하는 실수

  • top의 프로세스 CPU만 본다. 어느 스레드가 쓰는지 모른다. top -H로 스레드 단위로 본다.
  • 스레드 수를 메모리 계산에서 뺀다. 스택과 스레드별 버퍼가 힙 밖에서 쌓인다.
  • fork 뒤 스레드가 그대로 있다고 생각한다. 멀티스레드 프로세스에서 fork()하면 자식에는 호출한 스레드 하나만 복제된다. 다른 스레드가 잡고 있던 락은 풀리지 않은 채 남을 수 있다.
  • JVM OOM과 커널 OOM kill을 섞어 본다. 원인과 대응이 다르다.
  • 컨테이너 pids.max를 모른다. 메모리가 남아도 스레드를 못 만든다.

점검표

  • 프로세스·스레드가 커널에서 같은 태스크라는 전제로 지표를 읽는다
  • CPU 문제는 top -H로 TID를 찾고 스레드 덤프 nid와 맞춘다
  • 스레드 수와 스택 크기(-Xss)를 메모리 계산에 넣었다
  • 컨테이너의 pids.max, threads-max를 확인했다
  • 컨텍스트 스위치를 자발·비자발로 나눠 본다
  • 커널 OOM kill 여부를 확인하는 방법을 알고 있다
  • I/O 대기가 많은 서비스는 가상 스레드를 검토했다

커널에게 프로세스와 스레드는 같은 태스크이고, 차이는 무엇을 공유하느냐다. 공유가 많을수록 만들고 바꾸기는 가볍지만 서로의 실수에 함께 쓰러진다. 이 한 문장이 스레드 수, 메모리, CPU, 장애 격리 판단의 기준이 된다.

댓글 0개

아직 댓글이 없습니다. 첫 댓글을 남겨보세요.

댓글 쓰기

← 목록으로 돌아가기