빠른 프로세스 작성 처리
fork() 함수 고속화: 카피 온 라이트
fork() 함수를 호출할 때 부모 프로세스의 메모리를 자식 프로세스에 모두 복사하는 것이 아니라, 페이지 테이블만 복사한다. 페이지 테이블 엔트리 내부에는 페이지에 쓰기 권한을 관리하는 필드가 있는데 이때 부모와 자식 양쪽을 대상으로 모든 페이지에 쓰기 권한을 무효화 한다.
부모 프로세스의 페이지 테이블
가상 주소 | 물리 주소 | 쓰기 권한 |
0 - 100 | 500 - 600 | X |
100 - 200 | 600 - 700 | X |
자식 프로세스의 페이지 테이블
가상 주소 | 물리 주소 | 쓰기 권한 |
0 - 100 | 500 - 600 | X |
100 - 200 | 600 - 700 | X |
이후, 메모리를 읽을 때 부모와 자식 사이에 공유된 물리 페이지에 접근 가능하다. 그러다 둘 중 하나가 data를 갱신하려고 하면, 그때는 페이지 공유를 해제하고, 프로세스마다 전용 페이지를 만든다.
자식 프로세스가 페이지 data를 갱신하면 다음과 같은 순서로 페이지가 복사된다.
- 쓰기 권한이 없으므로 CPU에서 페이지 폴트가 발생한다.
- CPU가 커널 모드로 바뀌고 커널의 페이지 폴트 핸들러가 동작한다.
- 페이지 폴트 핸들러는 접속한 페이지를 별도의 물리 메모리에 복사한다.
- 자식 프로세스가 변경하려고 했던 페이지에 해당하는 페이지 테이블 엔트리를 부모와 자식 프로세스를 대상으로 모두 변경한다.
부모 프로세스의 페이지 테이블
가상 주소 | 물리 주소 | 쓰기 권한 |
0 - 100 | 500 - 600 | X |
100 - 200 | 600 - 700 | O |
자식 프로세스의 페이지 테이블
가상 주소 | 물리 주소 | 쓰기 권한 |
0 - 100 | 500 - 600 | X |
100 - 200 | 700 - 800 | O |
즉 600 - 700 은 부모 프로세스의 메모리, 700 - 800은 자식 프로세스의 메모리로 새로 할당되는데, write하지 않은 곳(data가 갱신되지 않은 곳)은 그대로 공유 메모리로 남는다.
위와 같이 fork() 함수를 호출할 때가 아니라 이후에 각 페이지에 처음으로 쓰기를 할 때 데이터를 복사하는 방식을 Copy On Write라고 한다. fork() 함수 처리가 빨라지고 메모리 사용량도 준다. 게다가 프로세스를 생성했을 때 모든 메모리에 접근하여 write를 하는 작업은 드문 일이어서 시스템 전체 메모리 사용량도 줄어든다.
특히 RSS 값이 변하지 않는다 -> 각 프로세스의 페이지 테이블 내부에서 물리 메모리가 할당된 메모리 영역 합계(물리 메모리가 미할당에서 할당으로 바뀌는 것이 아니라, 실제 할당받은 물리메모리 주소만 변경됨)
execve() 함수의 고속화: Demand paging
아래와 같이 execve() 함수 호출 직후라면 프로세스용 물리 메모리는 아직 할당되지 않았다.
이후 프로그램이 엔트리 포인트에서 실행을 시작하면 엔트리 포인트에 대응하는 페이지가 존재하지 않기 때문에, 페이지 폴트가 발생하는데, 이후 실제 물리 메모리가 할당된다.
가상 주소 | 물리 주소 |
0 - 100 | X(접근 이후 할당) |
100 - 200 | X(접근 이후 할당) |
프로세스 통신
프로세스끼리 데이터를 공유하거나 서로 타이밍을 맞춰서(동기화해서) 처리해야 하는 경우, OS가 제공하는 기능이 프로세스 통신이다.
아래와 같은 방법들로 프로세스 통신을 제공할 수 있다.
공유 메모리
프로세스 A의 페이지 테이블
가상 주소 | 물리 주소 |
0 - 100 | 500 - 600 |
100 - 200 | 600 - 700 |
200 - 300 | 700 - 800 |
프로세스 B의 페이지 테이블
가상 주소 | 물리 주소 |
0 - 100 | 800 - 900 |
100 - 200 | 700 - 800 |
mmap()을 사용하여 MAP_SHARED 플래그를 통해 공유 메모리를 생성할 수 있다.
위와 같이, 프로세스 A와 프로세스 B가 물리 주소 700 - 800 범위에 대해 공유 메모리방식으로 data를 read/write 할 수 있게 된다.
시그널
시그널도 프로세스 통신에 포함된다. 예를들어 POSIX에는 SIGUSR1과 SIGUSR2 처럼 프로그래머가 자유롭게 용도를 정하면 되는 시그널이 있다. 이런 시그널을 통해 두 프로세스가 시그널을 주고받으며 진행 정도를 확인하며 처리를 진행할 수 있다.
다만 시그널은 무척 원시적인 구조라서 시그널 신호를 받는 쪽으로는 시그널 도착 여부 같은 정보 밖에 보낼 수 없기 때문에 데이터는 또 다른 방법을 써서 주고받는 등 제약이 많다.
단순한 일을 할 때만 사용하는것이 낫다.
파이프
가장 친숙한 예로는 bash같은 셸에서 | 문자로 프로그램끼리 처리 결과를 연계하는 것이다.
ex) free | awk '(NR==2){print $2}'
위 명령어를 실행하면 bash는 free와 awk를 파이프로 연결해 free 출력 > awk 입력으로 넘기게 된다.
소켓
소켓은 크게 두 종류가 있다.
- UNIX domain socket: 같은 기기에 있는 프로세스 사이에서만 통신하는 방법
- TCP/UDP socket: 인터넷 프로토콜 suite 또는 TCP/IP 프로토콜(규약)에 따라서 여러 프로세스와 통신한다. 유닉스 도메인 소켓에 비해 속도가 느린편이나 다른 기기에 있는 프로세스 사이에도 통신이 가능함.
배타적 제어
시스템에 존재하는 자원에는 동시에 접근하면 안되는 것들이 많다. 예를들어 우분투 패키지 관리 시스템의 database 등이 있다.
패키지 관리를 할때, 동시에 apt가 동작하며 패키지 설치/삭제등을 하여 database가 훼손된다면 패키지를 사용하기 어려울 것이다.
이런 문제를 방지하기 위해 어떤 자원에 한 번에 하나의 처리만 접근 가능하게 관리하는 배타적 제어구조가 존재한다.
예를 들어, test.txt 파일에 숫자 0을 작성하고, 해당 숫자를 증가시키는 프로그램(count.sh)을 수행한다고 가정해보자.
for문을 통해 숫자를 1000번 증가시키면 -> 해당 파일에는 1000이라는 숫자로 업데이트 될 것이다.
해당 프로그램을 ./count.sh & 병렬실행하면 어떻게 될까?
아래와 같은 병렬 실행으로 인해 1000이라는 숫자는 나오지 않을것이다.
1. count.sh 프로그램 A가 txt 파일에서 0을 읽음
2. count.sh 프로그램 B가 txt 파일에서 0을 읽음
3. count.sh 프로그램 A가 txt 파일에 1을 write
4. count.sh 프로그램 B가 txt 파일에 1을 write
즉, 읽고 쓰는 행위가 병렬적으로 동시에 읽어날 때 한번에 하나의 프로그램에서만 실행되는 것이 보장되어야 한다.
실제로 구현하는 방법이 상호 배제(mutual exclusion)이다.
- critical section(크리티컬 섹션, 임계 구역): 동시에 실행되면 안되는 처리 흐름, 즉 위의 예제에서는 txt 파일을 읽어서 1을 더하고 write 하기까지에 해당
- atomic 처리: 시스템 외부에서 봤을때 하나의 처리로 다루어야하는 처리 흐름. 예를들어 위의 예제에서 critical section이 atomic 하다면, 1,3번사이에 2번 동작이 끼어들 수 없게 된다.
locking을 통해서, 문제가 생기지 않도록 어떻게 할 수 있나?
File lock은 flock()이나 fcntl() 시스템 콜을 사용해서 어떤 파일의 lock/unlock 상태를 변경한다.(아토믹하게 실행됨)
1. 파일이 lock 상태인지 확인한다.
2. lock 상태라면 시스템 콜이 실패한다.
3. unlock 상태라면 lock 상태로 바뀌고 시스템 콜이 성공한다.
locking을 통해서, 내가 지금 critical section에서 작업을 해도되는지, 문제가 생기지 않도록 상호 배제를 구현할 수 있다.
돌고 도는 배타적 제어
결국 assembly 단으로 내려가더라도, data load/store 과정이 atomic 하지 않게되면 data loss가 일어날 수 있다.
즉 CPU 아키텍처에서 critical section을 atomic으로 수행할 수 있게 compare and exchange, compare and swap 등을 제공한다.
멀티 프로세스와 멀티 스레드
CPU 멀티 코어와 추세로 프로그램 병렬 동작의 중요성이 높아졌다. 프로그램을 병렬 동작시키는 방법은 두 가지가 있는데, 하나는 전혀 다른 일을 하는 여러 프로그램을 동시에 동작시키는 것이고, 또 다른 방법은 어떤 목적을 지닌 하나의 프로그램을 여러개의 흐름으로 분할해서 실행하는 것이다.
분할 실행방법은 크게 두가지가 있는데, 멀티 프로세스 / 멀티 스레드이다.
멀티 프로세스
- fork(), execve()를 사용해서 필요한 만큼 프로세스를 생성하고, 이후에 각자 프로세스끼리 통신 기능을 사용해서 처리한다.
멀티 스레드
- 프로세스 내부에 여러 개의 흐름을 작성한다. 스레드는 프로세스 메모리 주소공간을 공유한다(fork(), execve()이 아니니)
- 페이지 테이블 복사가 필요 없어서 생성 시간이 짧다.
- 다양한 자원을 동일한 프로세스 내부의 모든 스레드가 공유하므로 메모리를 비롯한 자원 소비량이 적다.
- 모든 스레드가 메모리를 공유하니, 협조해서 동작하기 쉽다.
- 하지만 하나의 스레드에서 발생한 장애 -> 모든 스레드에 영향을 준다.(비정상적인 주소 참조를 했다고 가정하면, 프로세스 전체가 이상 종료됨)
- 각 스레드에서 호출하는 처리가 thread safe한지, 즉 배타적 제어가 고려되었는지 생각해야한다.
커널 스레드와 사용자 스레드
thread 구현 방법은 커널 공간에서 구현하는 kernel thread와 사용자 공간에서 구현하는 user thread 두 종류로 크게 나뉜다.
kernel thread
어떤 프로세스가 생성될 때 커널은 하나의 커널 스레드를 작성한다. 스케줄러가 관리하는 스케줄링 대상은 여기서 말하는 커널 스레드다.
이 프로세스에서 clone() 시스템 콜을 호출하면 커널은 새로 생성한 스레드에 대응하는 또 다른 커널 스레드를 만든다. 이때 프로세스 내부의 스레드는 동시에 서로 다른 논리 CPU에서 동작한다.
리눅스가 프로세스를 작성할 때, 즉 fork() 함수를 호출할 때나 스레드를 작성할 때나 모두 clone() 시스템 콜을 사용한다.
clone() 시스템 콜은 기존 커널 스레드와 새로 생성한 커널 스레드 사이에 어떤 자원을 공유할지 정할 수 있다.
프로세스 생성(fork() 함수 호출)은 가상 주소 공간을 공유하지 않고, 스레드 생성은 가상 주소 공간을 공유한다.
user thread
clone() 시스템 콜을 사용하지 않고 사용자 공간 프로그램, 예를 들어 thread 라이브러리를 가지고 구현하는 것이 user thread다.
다음에 실행할 명령에 대한 정보는 스레드 라이브러리 안에 저장되어 있음. 어떤 thread가 I/O 호출등으로 대기 상태가 발생하면 스레드 라이브러리가 동작해서 다른 스레드로 실행을 전환한다. 프로세스 내부에 여러 개의 사용자 thread가 있더라고 커널에서 보면 하나의 커널 스레드인 것처럼 보이므로, 모든 사용자 스레드는 동일한 논리 CPU에서만 실행된다.
커널 스레드와 사용자 스레드 차이점(물리 메모리 레이아웃)
커널 스레드 | 사용자 스레드 |
커널 메모리 - 프로세스 A의 스레드 0 커널 스레드 정보 - 프로세스 A의 스레드 1 커널 스레드 정보 |
커널메모리 - 프로세스 A의 커널 스레드 |
프로세스 A 메모리 | 프로세스 A 메모리 - 스레드 0 정보 - 스레드 1 정보 |
커널 스레드
프로세스 A 스레드 0 | 프로세스 A 스레드 1 | 프로세스 B | 프로세스 A 스레드 0 |
사용자 스레드
프로세스 A | 프로세스 B | 프로세스 A | 프로세스 B |
커널 스레드인 경우 프로세스 A의 스레드 0, 스레드 1을 프로세스 B와 동등하게 취급하므로 순서대로 CPU를 사용하게 된다. 하지만 사용자 스레드는 커널 스케줄링 관점에서 보면 커널은 프로세스 A의 스레드 0과 스레드 1을 구별할 수 없다. 따라서 프로세스 A와 프로세스 B가 번갈아가며 CPU를 사용한다.
프로세스 A에 CPU가 돌아왔을때, 스레드 0과 스레드 1중에 어떻게 CPU르 배분할지는 스레드 라이브러리가 책임진다.
커널 스레드는 논리 CPU가 여러 개일 때 동시 실행 가능하다는 장점이 있지만 생성 비용, 스레드 실행 전환 비용은 사용자 스레드 쪽이 낮다.
커널이 직접 커널 스레드를 만들기도 하는데 [kthreadd] 나 [rcu_gp] 같이 ps aux결과에서 COMMAND 필드에 있는 문자열이 [ ] 으로 감싸져 있으면 커널이 만든 스레드다.
커널이 만든 커널 스레드의 트리 구조는 프로세스와 다르게 kthreadd가 루트가 된다. 리눅스 커널은 실행 시작부터 초기 단계에 PID=2인 kthreadd를 기동하고, 필요에 따라 kthreadd가 자식 커널 스레드를 실행한다.
'Linux' 카테고리의 다른 글
리눅스 스터디 #7 파일 시스템 (0) | 2025.01.19 |
---|---|
리눅스 스터디 #6 장치 접근 (0) | 2025.01.19 |
리눅스 스터디 #4 메모리 관리 시스템 (0) | 2025.01.19 |
리눅스 스터디 #3 프로세스 스케줄러 (0) | 2025.01.19 |
리눅스 스터디 #2 프로세스 관리 (0) | 2025.01.19 |