다음은 프로세스의 메모리 영역 구조이다.
프로세스에서 실제로 실행되는 흐름의 단위. 스택을 할당 받는다.
다대일 모델
하나의 사용자 레벨 스레드가 시스템 콜을 호출하면 나머지 사용자 레벨 스레드는 커널 레벨 스레드에 접근할 수 없음 → 멀티 코어 병렬성 활용 X
일대일 모델
불필요한 커널 레벨 스레드가 생성되므로 성능이 저하됨
다대다 모델
구현이 힘듦
프로세스 복사 시
fork() 호출 시 부모 프로세스는 자식 프로세스 PID 값을, 자식 프로세스는 0을 반환한다.
#include <stdio.h>
#include <unistd.h>
int main() {
printf("start!\n");
int forkRet = fork();
if (forkRet == 0) {
printf("child process %d\n", getpid());
} else {
printf("forkRet:%d parent process:%d\n", forkRet, getpid());
}
return 0;
}
start!
forkRet:38668 parent process:38667
child process 38668
import time
from multiprocessing import Process, cpu_count
# 팩토리얼 계산 함수
def factorial(n):
result = 1
for i in range(1, n + 1):
result *= i
return result
# 멀티 프로세스에서 실행할 타겟 함수
def compute_factorials():
factorial(100000)
def single_process():
# 단일 프로세스 실행 시간 측정
start_time = time.time()
results = [factorial(100000) for _ in range(2)] # 팩토리얼 두 번 계산
end_time = time.time()
print(f"Single Process Time: {end_time - start_time} seconds")
def multi_process():
# 멀티 프로세스 실행 시간 측정
start_time = time.time()
processes = []
num_processes = 2 # 동시에 실행할 프로세스의 수
for _ in range(num_processes):
p = Process(target=compute_factorials)
processes.append(p)
p.start()
for p in processes:
p.join() # 모든 프로세스의 종료를 기다림
end_time = time.time()
print(f"Multi Process Time: {end_time - start_time} seconds")
if __name__ == '__main__':
single_process() # 단일 프로세스로 실행
multi_process() # 멀티 프로세스로 실행
Single Process Time: 7.751401901245117 seconds
Multi Process Time: 3.926076889038086 seconds
import threading
import time
# CPU 바운드 작업을 수행하는 함수
def cpu_bound_task(n):
return sum(i*i for i in range(n))
def single_threaded(n):
start_time = time.time()
results = []
for _ in range(n):
result = cpu_bound_task(10**7)
results.append(result)
end_time = time.time()
print(f"싱글 스레딩 결과: {sum(results)}, 시간: {end_time - start_time}초")
def multi_threaded(n):
threads = []
results = [0] * n
start_time = time.time()
for i in range(n):
# 각 스레드에 작업 분배
thread = threading.Thread(target=lambda q, idx: q.__setitem__(idx, cpu_bound_task(10**7)), args=(results, i))
threads.append(thread)
thread.start()
for thread in threads:
thread.join() # 모든 스레드의 작업이 끝날 때까지 기다림
end_time = time.time()
print(f"멀티 스레딩 결과: {sum(results)}, 시간: {end_time - start_time}초")
if __name__ == "__main__":
print("싱글 스레딩 실행 중...")
single_threaded(4) # 4번 반복 실행
print("\n멀티 스레딩 실행 중...")
multi_threaded(4) # 4개의 스레드 생성
싱글 스레딩 실행 중...
싱글 스레딩 결과: 1333333133333340000000, 시간: 1.877094030380249초
멀티 스레딩 실행 중...
멀티 스레딩 결과: 1333333133333340000000, 시간: 1.8406879901885986초
4개 스레드에 작업을 분배해도 시간 차이가 거의 나지 않는다. 그 이유는 이제 설명할 GIL 때문이다.
흔히 파이썬을 처음 배울 때 인터프리터 언어라고 배울 것이다. 그리고 인터프리터 언어와 비교하는 개념으로 C와 같은 컴파일 언어가 나온다. 그럼 파이썬은 컴파일 언어일까?
우리가 흔히 사용하는 파이썬은 주로 CPython이다. 그리고 이 CPython은 인터프리터로 파이썬 코드를 그대로 실행하지 않는다. 파이썬 코드가 바이트코드로 바뀌는 컴파일 과정이 이루어진다.
GIL은 한 시점에 단 하나의 스레드만 파이썬 바이트코드를 실행할 수 있도록 하는 락(lock)이다. CPython 인터프리터 내의 코드 실행이 동시에 발생하지 않도록 보장하는 역할을 한다.
이전 포스팅에서 JVM 계열 언어와 파이썬의 GC 동작 방식이 유사하다고 했다. 특히 young/old 세대 방식 GC로 살아남은 객체만 살려 놓는 방식은 파이썬에서도 유효하게 동작한다.
이전 포스팅에서 reference counting 계산 방식 GC를 더 자세하게 설명했다. 다시 요약하자면 파이썬 GC는 자신을 참조하는 객체가 아무도 없어지면(reference count가 0이 되면) GC 대상이 되는데, GIL은 이 과정에서 reference counting이 겹쳐서 일어나지 않도록 조절한다.
GIL은 단일 락으로 동작한다. 이 락은 스레드가 파이썬 바이트코드를 실행하기 전 획득해야 한다. 만약 스레드가 GIL를 획득한 경우, 다른 스레드는 GIL을 획득할 때까지 실행할 수 없다. 따라서 모든 CPython 바이트코드 실행은 직렬화된다. 일반적으로 스레드가 할당된 시간을 사용하면 GIL를 해제하고 다른 스레드가 실행되도록 한다.
I/O 바운드 작업은 입출력에 의해 성능이 결정되는 작업을 의미한다. I/O 작업에 많은 시간을 소모하는 작업이 I/O 바운드 작업이다. I/O 바운드 작업에서는 대부분의 시간이 I/O 작업을 기다리는 데 소비된다. I/O 작업이 진행되는 동안 GIL을 해제하고 다른 스레드가 실행될 수 있으므로 I/O 바운드 상황에서는 GIL의 영향이 상대적으로 덜 민감하다. 이후에 언급할 코루틴이 I/O 바운드 작업을 최적화하기 때문에 독립적으로 작용한다.
CPU 바운드 작업은 CPU 연산에 의해 성능이 결정되는 작업을 의미한다. GIL은 한 시점에 하나의 스레드만이 CPU에서 실행될 수 있도록 한다. 이는 멀티 코어 환경에서도 마찬가지인다. 멀티 코어라고 해도 오직 하나의 스레드만이 실행될 수 있으므로 성능 개선이 이루어지지 않는다.
import time
import threading
import requests
from concurrent.futures import ThreadPoolExecutor
# CPU 바운드 작업
def cpu_bound_task(n):
return sum(i*i for i in range(n))
# I/O 바운드 작업
def io_bound_task(url):
response = requests.get(url)
return response.status_code
# 싱글 스레딩 실행 함수
def run_single_threaded(tasks, task_type):
start_time = time.time()
results = []
for task in tasks:
results.append(task_type(task))
end_time = time.time()
print(f"싱글 스레딩 결과: {len(results)}개, 시간: {end_time - start_time:.2f}초")
# 멀티 스레딩 실행 함수
def run_multi_threaded(tasks, task_type):
start_time = time.time()
with ThreadPoolExecutor(max_workers=4) as executor:
results = list(executor.map(task_type, tasks))
end_time = time.time()
print(f"멀티 스레딩 결과: {len(results)}개, 시간: {end_time - start_time:.2f}초")
if __name__ == "__main__":
n = 10**7
urls = ["https://www.example.com" for _ in range(4)] # 동일한 URL로 4개의 요청
print("CPU 바운드 작업 (싱글 스레딩 vs 멀티 스레딩)")
run_single_threaded([n] * 4, cpu_bound_task)
run_multi_threaded([n] * 4, cpu_bound_task)
print("\nI/O 바운드 작업 (싱글 스레딩 vs 멀티 스레딩)")
run_single_threaded(urls, io_bound_task)
run_multi_threaded(urls, io_bound_task)
CPU 바운드 작업 (싱글 스레딩 vs 멀티 스레딩)
싱글 스레딩 결과: 4개, 시간: 1.86초
멀티 스레딩 결과: 4개, 시간: 1.78초
I/O 바운드 작업 (싱글 스레딩 vs 멀티 스레딩)
싱글 스레딩 결과: 4개, 시간: 4.08초
멀티 스레딩 결과: 4개, 시간: 1.15초
CPython이 아닌 JVM 기반 Jython으로 GIL 문제를 해결할 수 있다. 하지만 이는 서드파티 플러그인 지원이 안되고 최신 파이썬 문법이 적용되지 않을 수 있다.
# -*- coding: utf-8 -*-
from java.util.concurrent import Executors, TimeUnit
from java.net import URL
from java.lang import Runnable
from java.lang import System as JavaSystem
# CPU 바운드 작업
class SumTask(Runnable):
def __init__(self, n):
self.n = n
def run(self):
total = sum(i for i in range(self.n))
print("Sum:", total)
# I/O 바운드 작업
class DownloadTask(Runnable):
def __init__(self, url):
self.url = url
def run(self):
content = URL(self.url).openStream()
content.close()
print("Download completed")
# 작업 실행 함수
def execute_tasks_single_threaded(tasks):
start_time = JavaSystem.currentTimeMillis()
for task in tasks:
task.run()
end_time = JavaSystem.currentTimeMillis()
print("Single Thread:", (end_time - start_time), "ms")
def execute_tasks_multi_threaded(tasks):
executor = Executors.newFixedThreadPool(4)
start_time = JavaSystem.currentTimeMillis()
for task in tasks:
executor.submit(task)
executor.shutdown()
executor.awaitTermination(60, TimeUnit.SECONDS)
end_time = JavaSystem.currentTimeMillis()
print("Multi Thread:", (end_time - start_time), "ms")
if __name__ == '__main__':
# CPU 바운드 작업 비교
print("CPU Bound")
n = 10000000
cpu_tasks = [SumTask(n) for _ in range(4)]
execute_tasks_single_threaded(cpu_tasks)
execute_tasks_multi_threaded(cpu_tasks)
# I/O 바운드 작업 비교
print("\nI/O Bound")
urls = ["http://www.example.com" for _ in range(4)]
io_tasks = [DownloadTask(url) for url in urls]
execute_tasks_single_threaded(io_tasks)
execute_tasks_multi_threaded(io_tasks)
메모리 outbound 에러가 발생해 2GB 정도로 할당했다.
(cs_study) chan@gang-gamchan-ui-MacBookPro CS_study % jython -J-Xmx2048m gamchan/jython_bound.py
CPU Bound
('Sum:', 49999995000000L)
('Sum:', 49999995000000L)
('Sum:', 49999995000000L)
('Sum:', 49999995000000L)
('Single Thread:', 7386L, 'ms')
('Sum:', 49999995000000L)
('Sum:', 49999995000000L)
('Sum:', 49999995000000L)
('Sum:', 49999995000000L)
('Multi Thread:', 2481L, 'ms')
I/O Bound
Download completed
Download completed
Download completed
Download completed
('Single Thread:', 1255L, 'ms')
Download completed
Download completed
Download completedDownload completed
('Multi Thread:', 613L, 'ms')
코루틴은 프로그램의 실행 중에 멈췄다가 필요한 시점에 다시 시작할 수 있는 독립적인 코드 블록이며 함수의 일종이다. 코루틴은 yield 키워드를 사용하는 제네레이터의 확장된 형태로 시작되었다. 가장 흔히 사용하는 제네레이터는 range() 이다. range(4)이면 0, 1, 2, 3이 동시에 튀어나오는 것이 아니라 순차적으로 튀어나온다.
import asyncio
async def async_generator():
for item in range(3):
# 비동기적으로 일정 시간을 기다립니다.
await asyncio.sleep(1)
yield f"Item {item}"
async def main():
# 비동기 제너레이터를 사용합니다.
async for item in async_generator():
print(item)
# 비동기 메인 함수를 실행합니다.
asyncio.run(main())