[프로세스와 스레드] 01. 프로세스와 스레드

출처: 컴퓨터 사이언스 부트캠프 with 파이썬 (양태환, 길벗)

멀티프로세스와 멀티스레드를 자주 사용하게 되었는데, 프로세스/스레드의 개념과 파이썬의 멀티프로세싱/멀티스레딩의 차이를 알아봅니다. 이미지와 글의 출처는 참고로 명시한 책에서 가져왔습니다.

1. 프로세스

  • 프로그램: 하드디스크에 저장된 실행 파일. 실행하지 않는 이상 하드디스크에 계속 남아 있으며, 같은 경로에 같은 이름으로 동시에 존재할 수는 없다.
  • 프로세스: 프로그램을 실행한 상태. 하드디스크에서 메인 메모리로 코드와 데이터를 가져와 현재 실행되고 있는 상태. 프로세스는 동시에 여러 개가 존재할 수 있음

1.1 프로세스 상태

프로세스를 실행하려면 독자적인 메모리 공간과 CPU가 필요하다. 메모리는 가상 메모리를 사용해서 해결한다. CPU는 한번에 하나의 프로세스에만 할당할 수 있다. 여러 프로세스가 완벽하게 ‘동시에’ 실행되는 건 불가능하다. 프로세스 상태는 상황에 따라 변한다.

  1. 생성 (Created): 프로그램을 더블클릭했을 때 프로세스가 생성되면서 실행 가능 상태가 된다. 바로 실행되는 것이 아니라, 우선 실행 가능 상태가 되어 실행 중인 프로세스와 우선순위를 비교한 다음 실행하거나 순서를 기다린다.
  2. 실행 가능 (Waiting): 실행 가능 상태의 프로세스는 언제든지 실행할 준비가 되어 있다. 운영체제는 인터럽트가 발생했을 때 실행 가능 상태의 프로세스 중 다음으로 CPU를 할당받아 실행될 프로세스에 CPU를 할당받아 실행될 프로세스에 결정한 후, 실행 중인 프로세스와 교체한다. 이 때 다음으로 실행될 프로세스에 CPU를 할당하는 것을 디스패치(dispatch)라고 하고, 실행 중이던 프로세스에서 CPU를 해제하는 것을 프리엠션(preemption)이라고 한다.
  3. 실행 (Running): 프로세스가 운영체제로부터 CPU를 할당받아 실행되고 있는 상태
  4. 보류 (Blocked): 프로세스가 I/O 작업을 하면 CPU를 해제하고 보류 상태로 변경된다. 이 때 실행 가능 상태의 프로세스 중 하나가 CPU를 할당받는다. I/O 작업이 완료된 다음 바로 실행 가능 상태로 변경되는 것이 아니라, 실행 가능 상태가 되어 실행되기를 기다린다. 또 Waiting는 언제든지 다시 실행될 수 있는 상태를 말하지만, 보류 상태는 I/O 작업이 끝나기 전에는 실행이 불가능한 상태이다.
  5. 소멸 (Terminated): 프로세스 실행이 완료되어 메인 메로리에서 사라진다.

1.2 스케줄링

스케줄링(scheduling)이란 운영체제가 여러 프로세스의 CPU 할당 순서를 결정하는 것이다. 이 일을 하는 프로그램을 스케줄러라고 한다.

스케줄링은 CPU를 언제 할당하는지에 따라 선점형 스케줄링(preemptive scheduling)과 비선점형 스케줄링(non-preemptive scheduling)으로 나눌 수 있다.

선점형 스케줄링에서는 어떤 프로세스가 실행 중에 있어도 스케줄러가 강제로 실행을 중지하고 다른 프로세스에 CPU를 할당할 수 있다. 비선점형 스케줄링에서는 실행 중인 프로세스가 종료되거나 I/O 작업에 들어가거나 명시적으로 CPU를 반환하기 전까지는 계속해서 실행된다. 우선순위가 높은 프로세스가 생성되어도 실행 중인 프로세스가 자발적으로 CPU를 양보하기 전까지는 실행될 수 없다.

1.3 컨텍스트 스위칭

프로세스 두 개가 같은 프로그램에서 만들어졌을 때 두 프로세스는 독립된 메모리 공간을 가진다. 프로세스가 실행되려면 다양한 CPU 레지스터 값과 프로세스 상태 정보 등이 필요하다. 그러므로 프로세스가 실행 상태에서 실행 가능 상태로 변경될 때 이러한 정보를 메모리 어딘가에 저장해야 한다. 프로세스의 CPU 상태와 프로세스의 상태를 저장해 둔 메모리 블록을 프로세스 제어 블록(Process Control Block, PCB)이라고 한다.

스케줄러가 실행 중인 프로세스에서 CPU를 해제하고 실행 가능 상태의 프로세스에 CPU를 할당할 때, 실행 중인 프로세스의 CPU 상태 정보를 그 프로세스의 PCBdp 저장하고 곧 실행될 프로세스의 PCB에서 이전 CPU 상태 정보를 CPU로 가져오는 것을 컨텍스트 스위칭(context switching)이라고 한다. CPU 상태를 컨텍스트라고 부르는데 말 그대로 현재 CPU의 레지스터 값들을 전환하는 것이다.

2. 스레드

스레드(thread)란 프로세스 안의 실행 흐름의 단위로 스케줄러에 의해 CPU를 할당받을 수 있는 인스트럭션의 나열이다. 프로세스는 하나 이상의 스레드로 구성된다.

프로세스가 PCB를 갖는 것처럼 스레드는 스레드 제어 블록(Thread Control Bock, TCB)을 갖는다. TCB에는 스레드 ID, 각종 레지스터 정보, 스레드 상태 정보, 스레드가 속해 있는 프로세스의 TCB 주소 등이 저장되어 있다.

프로세스와 스레드 모두 인스트럭션의 나열이고 유사한 정보가 든 메모리 블록을 갖는다. 프로세스가 단일 스레드로 작동하면 프로세스와 스레드는 차이가 없다. 프로세스와 스레드의 차이점을 알려면 멀티프로세스와 멀티스레드를 비교해야 한다.

2.1 멀티프로세스와 멀티스레드

단일 코어 CPU에서 여러 개의 실행 흐름이 동시에 필요하다고 가정하면, 실행 흐름사이에서 데이터를 공유해야 한다. 실행 흐름은 결국 CPU를 점유하고 인스트럭션을 실행하는 것을 말하므로 여러 실행 흐름을 구현하려면 멀티프로세스나 멀티스레드로 구현해야 한다.

먼저 멀티프로세스로 구현한다고 가정하면, 프로세스는 서로 독립적인 메모리 공간을 가지므로 기본적으로 데이터를 공유할 수 없다. 멀티프로세스에서는 모든 프로세스가 서로 다른 메로리 공간을 가지므로 데이터를 공유하려면 특별한 기법을 사용해야 한다.

멀티프로세스의 메모리 구조

하지만 멀티스레드로 구현하면 데이터를 쉽게 공유할 수 있다. 멀티프로세스와 달리 여러 스레드가 스택만 서로 다른 공간을 갖고, 코드, 데이터, 힙은 공유하기 때문이다.

멀티스레드의 메모리 구조

스레드는 각자 독립적인 스택 세그먼트를 갖지만, 코드, 데이터, 힙은 다른 스레드와 공유한다. 데이터 세그멘트나 힙 세그먼트에 공유 데이터를 두면 모든 스레드가 이용할 수 있다.

1
2
3
li = [i for i in range(1000+1)]
for idx in range(1000+1):
    li[idx] *= 2

아래 예는 위의 싱글 스레드 작업을 멀티 스레딩으로 바꾼 코드이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import threading

def thread_main(li, i):
    for i in range(offset*i, offset*(i+1)):
        li[i] *= 2

num_elem = 1000
num_thread = 4

offset = num_elem // num_thread

li = [i+1 for i in range(num_elem)]

threads = []
for i in range(num_thread):
    th = threading.Thread(target=thread_main, 
                          args=(li, i))
    threads.append(th)
    
for th in threads:
    th.start()
    
for th in threads:
    th.join()

print(li[:100])

>>>
[2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22, 24, 26, 28, 30, 32, 34, 36, 38, 40, 42, 44, 46, 48, 50, 52, 54, 56, 58, 60, 62, 64, 66, 68, 70, 72, 74, 76, 78, 80, 82, 84, 86, 88, 90, 92, 94, 96, 98, 100, 102, 104, 106, 108, 110, 112, 114, 116, 118, 120, 122, 124, 126, 128, 130, 132, 134, 136, 138, 140, 142, 144, 146, 148, 150, 152, 154, 156, 158, 160, 162, 164, 166, 168, 170, 172, 174, 176, 178, 180, 182, 184, 186, 188, 190, 192, 194, 196, 198, 200]

2.3 경쟁 조건

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import threading

g_count = 0

def thread_main():
    global g_count
    for i in range(100000):
        g_count += 1
        
threads = []

for i in range(50):
    th = threading.Thread(target=thread_main)
    threads.append(th)
    
for th in threads:
    th.start()
    
for th in threads:
    th.join()
    
print('g_count: {:,}'.format(g_count))

>>>
g_count: 4,902,146

전역 변수 g_count를 선언하고 thread_main() 함수 안에서 g_count 값에 1을 100,000번 더한다. 스레드를 총 50개 만들었으니 스레드 50개가 동시에 g_count에 접근해 값을 수정하려고 시도한다. 이처럼 여러 스레드가 동시에 접근, 수정, 공유 가능한 자원을 공유 자원이라고 한다. 위 코드에서는 g_count라는 전역 변수가 공유 자원이다.

g_count의 최종 값은 스레드가 각각 100,000번 씩 값을 증가시키므로 5,000,000이 될 것 같지만, 실행 결과는 매번 달라진다.

이상적인 경우에서는 먼저 전역 변수 g_count 값을 범용 레지스터로 가져와 값을 증가시키고, 연산이 끝난 레지스터 값을 g_count에 저장한다. 이제 컨텍스트 스위칭이 일어나 스레드 2에 CPU가 할당된다. 이 과정이 반복되는 것이 이상적인 경우이지만 선점형 스케줄링에서는 스레드 1의 연산이 완전히 끝날 때까지 컨텍스트 스위칭을 기다려 주지 않는다.

g_count에 값을 저장하기 전에 컨텍스트 스위칭이 일어나면 다른 스레드가 이전 레지스터 값을 복원하고 이전 상태에 이어 연산을 마무리한다. 각 스레드의 관점에서 보면 스레드는 첫 번째 경우나 두 번째 경우 모두 g_count 값에 1을 더하는 같은 연산을 하지만 컨텍스트 스위칭이 언제 일어나는지에 따라 전혀 다른 결과가 나온다. 이처럼 스레드 여러 개가 공유 자원엗 동시에 접근하는 것을 경쟁 조건(race condition)이라고 한다.

만약 스레드 안에 있는 코드가 공유 자원에 접근해 변경을 시도하는 코드(임계 영역, critical section)가 있으면 문제가 발생한다.

2.4 상호 배제

경쟁 조건 문제를 해결하기 위해서 상호 배제(mutual exclusion)을 사용한다. 상호 배제의 원리는 간단하며, 스레드 하나가 공유 자원을 이용하는 동안에는 다른 스레드가 접근하지 못하게 막는 것이다. 파이썬에서는 주로 Lock 객체를 활용한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import threading

g_count = 0

def thread_main():
    global g_count
    # 한 스레드가 lock을 획득하면
    # 획득을 시도한 나머지 스레드는 대기한다.
    lock.acquire()
    for i in range(100000):
        
        g_count += 1
    # lock 반환
    # 대기하던 스레드 중 하나가 획득
    lock.release()
        
lock = threading.Lock()        
threads = []

for i in range(50):
    th = threading.Thread(target=thread_main)
    threads.append(th)
    
for th in threads:
    th.start()
    
for th in threads:
    th.join()
    
print('g_count: {:,}'.format(g_count))

>>>
g_count: 5,000,000

이제 원하는 대로 결과가 나온다.