운영체제 기본/프로세스와 스레드
목차 · 이전: 보호와 시스템 호출 · 다음: 가상 메모리
디스크에 있는 실행 파일과 실행 중인 프로세스는 구분해야 한다. 같은 실행 파일을 두 번 실행하면 서로 다른 PID와 실행 상태를 가진 프로세스가 생길 수 있다. 프로세스에는 주소 공간, 열린 파일, 자격 정보 등 실행에 필요한 자원이 연결되어 있다.
새 프로세스와 새 프로그램
셸에서 외부 명령을 실행하는 전형적인 흐름은 자식 프로세스를 만들고, 그 프로세스가 실행할 프로그램을 교체하는 것이다. Linux의 전형적인 구현에서는 fork() 계열로 자식을 만들고 execve() 계열로 실행 이미지를 교체한다. 실제 셸 구현에는 다른 생성 방식이나 최적화가 쓰일 수 있고, 셸 내장 명령은 이 흐름을 따르지 않을 수도 있다.
fork()는 새 프로세스를 만드는 호출이고, execve()는 호출한 프로세스의 프로그램 이미지를 교체하는 호출이다. execve() 자체가 새 PID를 만드는 것은 아니다. 이 둘을 구별하면 “새 프로그램 실행”과 “새 프로세스 생성”을 혼동하지 않게 된다. fork(2) · execve(2)
스레드가 공유하는 것
프로세스 안에는 하나 이상의 스레드가 있을 수 있다. 같은 프로세스의 스레드는 주소 공간과 열린 파일 등의 자원을 공유하면서 각자의 실행 위치와 스택을 갖는다. 스택이 따로 있다는 말은 다른 스레드가 그 메모리에 절대로 접근할 수 없다는 뜻은 아니다. 같은 주소 공간을 공유하므로 포인터를 잘못 다루면 서로의 작업을 망가뜨릴 수 있다.
여러 스레드가 공유 변수에 접근할 때에는 실행 순서를 조정해야 할 수 있다. 스레드를 늘리는 것만으로 처리 속도가 비례해서 올라가지도 않는다. 같은 잠금을 기다리거나 같은 자원을 놓고 경쟁하면 기다리는 시간도 늘어난다. pthreads(7)
공유를 선택하면 조정할 책임도 생긴다
프로세스와 스레드를 고를 때는 생성 비용보다 먼저 공유 범위와 고장 범위를 생각해야 한다. 별도 주소 공간에서는 한쪽의 보통 메모리 쓰기가 다른 쪽의 사적인 메모리를 직접 덮어쓰지 않는다. 대신 결과를 전달할 통신 경로가 필요하다. 스레드는 포인터로 같은 객체를 쉽게 다루지만, 잘못된 쓰기도 공유 상태에 도달한다. 스레드별 스택은 서로 접근할 수 없는 보호 경계를 뜻하지 않는다.
공유는 접근 가능성만 제공하며 작업 순서는 제공하지 않는다. 재고가 하나 남았는데 두 작업이 모두 재고를 확인한 뒤 판매를 승인하면, 각 확인이 정확했어도 전체 결과는 틀릴 수 있다. 필요한 것은 “판매한 수량이 재고를 넘지 않는다”는 불변식이다. CPU가 하나여도 확인과 차감 사이에 다른 작업이 실행되면 이런 논리적 경쟁이 생긴다.
동기화의 단위는 코드 한 줄이 아니라 함께 보호해야 하는 상태 변화다. 상호 배제는 확인부터 갱신까지 다른 참여자가 끼어들지 못하게 하는 수단이다. 개별 연산이 원자적이어도 여러 연산의 조합까지 원자적이지는 않다. 프로세스로 나누어도 공유 메모리나 파일을 함께 변경하면 조정은 필요하다. 격리는 영향을 제한하고, 동기화는 함께 수행하는 일을 올바르게 만든다. 둘은 서로 대신할 수 없다.
참고: pthreads(7)의 공유 자원.
CPU를 기다리는가, 다른 일을 기다리는가
프로그램이 종료되지 않았다고 해서 계속 CPU에서 실행되는 것은 아니다. 터미널 입력, 네트워크 응답, 저장장치의 입출력, 다른 스레드의 잠금 해제를 기다릴 수 있다. 기다리는 동안 실행을 멈추고 다른 작업에 CPU를 내줄 수 있다.
스케줄러는 실행 가능한 작업 중에서 다음에 실행할 작업을 고른다. 따라서 CPU 사용률이 낮은데 프로그램이 느리다면 무조건 CPU 성능부터 의심할 일은 아니다. 무엇을 기다리고 있는지 살펴봐야 한다. 반대로 실행 가능한 작업이 많으면 자기 차례를 기다리는 시간도 길어질 수 있다. sched(7)
Linux의 상태 표시에서 R은 실행 중이거나 실행 가능한 상태를 포함한다. S는 인터럽트 가능한 수면, D는 인터럽트 불가능한 대기 상태이며 입출력 대기에서 흔히 보인다. 상태 한 글자만으로 정확히 어떤 파일이나 잠금을 기다리는지까지 알 수 있는 것은 아니다.
현재 셸을 관찰하기
Bash에서 다음 명령을 실행해 보자. $$는 현재 셸의 PID다.
cat /proc/$$/status
Pid와 PPid로 프로세스와 부모의 ID를, Threads로 스레드 수를 확인할 수 있다. State가 S (sleeping)로 보여도 셸이 멈췄다는 뜻은 아니다. 셸이 자식 명령인 cat의 종료를 기다리는 중일 수 있다. 이 명령은 cat의 상태가 아니라 셸의 상태를 읽는다.
출력은 관찰 순간의 값이므로 상태가 바뀔 수 있다. 프로세스가 존재하는지, 지금 실행 가능한지, 실제 CPU 시간을 얼마나 썼는지는 서로 다른 질문이다.