파이썬 동시성 프로그래밍에 대해 알아보자❕
멀티 스레딩은 하나의 프로세스 안에서 처리해야 할 여러 작업(A, B, C)가 있다면 프로세스의 자원을 공유해 동시에 수행하는 것을 의미하고, 비동기는 어떠한 작업이 완료되기를 기다리지 않고 뒤에 작업을 하는 것을 의미한다. 프로그램을 구현하다보면 성능을 위해 멀티 스레드 및 비동기 처리가 필요할 때가 있는데, 이번 시간에는 파이썬에서 이 둘의 동작과 적용하는 방법에 대해 정리해보려고 한다.
1. 동시성과 병렬성
동시에 실행된다는 것은 동시성과 병렬성으로 나누어 설명할 수 있다.
- 동시성(Concurrency) : 실제로 동시에 작업이 실행되지는 않지만, 마치 여러 작업을 동시에 하는 것처럼 느껴지게 하기 위해 시분할 처리하는 것 (멀티 스레드, 코루틴, 비동기 등)
- 병렬성(Parallelism) : 여러 작업이 실제로 동시에 수행되어 병렬로 처리하는 것 (여러 CPU 코어나 프로세서 이용)
즉, 논리적으로 동시에 실행하는 것은 동시성, 물리적으로 동시에 실행하는 것을 병렬성이라고 말한다.
+ 참고로 멀티 태스킹은 운영체제가 CPU 시간을 분할하여 여러 작업(프로세스, 스레드)을 교대로 처리하는 방식을 의미한다. 아주 짧은 시간동안 번갈아가면서 실행시키기 때문에 동시에 처리하는 것처럼 느껴지게 하는 것이다. (멀티 프로그래밍 + 시분할 처리)
따라서 동시성을 멀티태스킹의 하위 개념으로 보기도 한다. 동시성을 구현하는 방법 중 하나가 멀티태스킹일 수 있기 때문이다.
2. 파이썬 GIL
다음과 같이 파이썬에서 동일한 작업 `working()`에 대해 1. 단일 스레드와 2. 멀티 스레드로 실행시켜보면, 멀티 스레드로 실행시켰을 때가 더 빨리 끝날 것 같지만 결과는 그렇지 않다. 멀티 스레드에서는 약 5.4s가 소요되고, 단일 스레드에서는 약 4.2s가 소요된다.
import time
import threading
import random
list_len = 10_000_000
# CPU bound 작업
def working():
return sorted([random.random() for _ in range(list_len)])
# 1. 단일 스레드
st = time.time()
sorted_list1 = working()
sorted_list2 = working()
en = time.time()
print(f"single thread => {en - st:.4f}s")
# single thread => 4.2633s
# 2. 멀티 스레드
st = time.time()
threads = []
for i in range(2):
threads.append(threading.Thread(target=working))
threads[i].start()
for t in threads:
t.join()
en = time.time()
print(f"multi thread => {en - st:.4f}s")
# multi thread => 5.4506s
Q. 왜 단일스레드에서 더 빨리 끝나는 걸까?
파이썬은 인터프리터 언어로, 한 줄 씩 읽으면서 실행하는 프로그램이다. 따라서 실행마다 소스 코드를 한 줄 씩 번역해야 하므로 컴파일 언어보다 느리다는 단점이 있었다. 이러한 단점을 보완하고자 파이썬 인터프리터의 표준 구현체로 받아지고 있는 것이 CPython(싸이썬)이다.
CPython은 Python을 C언어로 구현한 구현체로, Python을 실행시키기 위한 인터프리터이면서 컴파일러라고 볼 수 있다. 이러한 ^CPython은 GIL(Global Interpreter Lock)이라는 특성을 가지고 있는데, 하나의 프로세스 내에서 여러 개의 스레드가 동시에 실행되지 못하게 하는 것이다.^
⇒ 파이썬은 기본적으로 GIL의 특성에 따라 하나의 프로세스는 하나의 스레드만 실행시킨다. 위 예에서 2개의 스레드를 만들어 `working()`을 실행시켜도 하나의 스레드만 실행되기 때문에, 오히려 2개의 스레드를 컨텍스트 스위칭하는 시간이 발생하면서 더 오래 걸린 것이다. (만약 GIL 환경에서 병렬성을 적용하고 싶다면 멀티프로세싱을 이용해야 한다)
참고로 GIL은 파이썬에서만 사용되는 용어가 아니다. 프로세스 당 하나의 기본 스레드만 실행될 수 있도록 스레드 실행을 동기화하기 위해 인터프리터에서 사용되는 메커니즘이다. ^GIL을 사용하는 인터프리터는 멀티코어 프로세서에서 실행되는 경우에도 항상 하나의 스레드만 실행되도록 허용한다.^ - 위키피디아
따라서, 파이썬에서 스레드를 여러개 만들어 실행시켜도 GIL의 특성에 따라 항상 하나의 스레드만 실행되어 단일 스레드로 동작하기 때문에 멀티 스레드의 이점이 없는 것이다.
GIL 흐름
스레드가 GIL을 획득하고 해제하는 경우는 다음 2가지에서 발생한다.
- *I/O bound 작업(I/O 작업이 주를 이루는 작업)
- 시간이 오래 걸리는 작업
기존에는 I/O bound 작업에서만 GIL을 획득하고 해제하였는데, Python3부터는 특정 스레드가 I/O bound 작업을 수행하지 않더라도 일정 시간(5ms)이 지나면 자동으로 GIL이 해제되고 다른 스레드가 GIL을 획득할 수 있다. (CPU bound 작업에서는 파이썬 자체에서 GIL을 자동 해제하는 메커니즘이 없어서, 실행 시간이 긴 작업은 `concurrent.futures.ProcessPoolExecutor`나 C 확장 모듈을 사용해 GIL을 우회한다)
* IO(input/output) 이란?
흔히 프로그래밍에서 I/O라고 하면 대표적으로 3가지를 나타낸다.
- 파일을 읽고 쓰는 것
- 서버 네트워크의 어딘가와 데이터를 주고 받는 것
- 입출력 장치와 데이터를 주고 받는 것
Q. 그럼 GIL이 적용된 환경에서 언제 성능 향상을 기대할 수 있을까?
위에서 확인한 것처럼 I/O bound한 작업과 시간이 오래 걸리는 작업에 한해서만 비동기적으로 처리되므로, 항상 성능이 향상되는 것은 아니다.
위에서 작성했던 `working()`을 다음과 같이 I/O bound 작업으로 변경하고, 단일 스레드와 멀티 스레드로 실행시켜보면 위에서 살펴본 것과는 다른 결과가 나온다. 단일 스레드는 약 6s, 멀티 스레드는 약 3s로 거의 2배 차이난다.
import time
import threading
# I/O bound 작업
def working():
time.sleep(3)
# 1. 단일 스레드
st = time.time()
working()
working()
en = time.time()
print(f"single thread => {en - st:.4f}s")
# single thread => 6.0078s
# 2. 멀티 스레드
st = time.time()
threads = []
for i in range(2):
threads.append(threading.Thread(target=working))
threads[i].start()
for t in threads:
t.join()
en = time.time()
print(f"multi thread => {en - st:.4f}s")
# multi thread => 3.0054s
이는 `working()`으로 실행하는 작업 time.sleep()
이 I/O bound 작업이기 때문에 GIL이 해제되면 다른 스레드가 GIL을 획득해 실행하게 되고, 이로 인해 두 스레드가 거의 동시에 실행되어 전체 실행 시간이 3초에 가깝게 나오게 되는 것이다. (동시성)
3. 코루틴 (coroutine)
파이썬은 GIL의 특성에 따라 하나의 스레드만 동작시키기 때문에, 한 번에 하나의 작업만 처리할 수 있다. 이 때, 코루틴을 사용하면 단일 스레드에서 비동기 및 동시성 작업을 효율적으로 처리할 수 있다.
- Co(cooperation) + Routine의 의미로, 상호협력하는 루틴을 의미한다. (상호 연계 프로그램, 상호 연계 함수)
- Python에서만 쓰이는 용어가 아닌 CS 전반에서 사용되는 용어이다.
- 일종의 함수로, 일반적인 함수와는 다르게 실행 중간에 일시 정지하고 다시 시작할 수 있다. 이러한 특징을 이용해 비동기 코드를 작성할 수 있다.
- 코루틴은 *제너레이터(generator)와 유사한 문법을 가지고 있다.
async
키워드로 제너레이터를 쉽게 작성할 수 있도록 하는 문법이다.
* 제너레이터(generator) ?
iterator를 생성해주는 함수안에 yield 키워드를 사용한다.
yield
키워드를 breakpoint로 삼아 실행을 중단 및 재개할 수 있다.
대략 다음과 같이 동작한다.
def generator_example():
while True:
x = yield
print('Received:', x)
gen = next(generator_example())
gen.send(10) # Received: 10
gen.send(20) # Received: 20
- 함수 안에 yield 키워드를 사용해 제너레이터를 생성한다.
- next()를 이용해 제너레이터를 호출한다.
- 제너레이터는 yield에서 중단되고 값이 전달될 때까지 기다린다.
제너레이터에 send()로 값을 보내면 yield에 전달되고, 그 다음 yield가 있다면 다시 send()를 기다린다.
스레드와 코루틴
그럼 코루틴과 멀티 스레드의 차이는 무엇일까. 스레드는 동시성을 보장하는데 있어 컨텍스트 스위칭 비용과 메모리 사용이 발생하는 반면, 코루틴은 컨텍스트 스위칭 비용이 없고 메모리 사용을 줄인 채로 동시성을 보장할 수 있다. 따라서 코루틴을 경량 스레드라고 말할 수 있다.
스레드와 코루틴은 모두 비동기 프로그래밍을 위해 사용될 수 있지만, 동작 방식에 차이가 있다.
- 스레드 : 운영체제에 의해 스케줄되고, 여러 스레드가 컨텐스트 스위칭을 통해 번갈아 실행된다.
- 코루틴 : 애플리케이션 레벨에서 실행이 제어되고, 컨텍스트 스위칭 없이 단일 스레드에서 동작한다.
GIL 메커니즘에 따르면 ThreadA에서 Task1을 수행하다 GIL이 해제되면 다른 스레드인 ThreadB가 GIL을 획득해 Task2를 수행한다. 그럼 기존 ThreadA는 block된다. 이 때 ThreadB로 컨텍스트 스위칭이 발생하고, Task2가 완료되어 GIL을 해제하면 기존 ThreadA가 GIL을 획득하면서 다시 컨텍스트 스위칭이 일어난다.
반면 코루틴은 ThreadA에서 Task1을 수행하다가, Task2 수행이 필요하면 동일한 스레드 내에서 비동기적으로 실행한다. 따라서 컨텍스트 스위칭 비용이 발생하지 않는다. Task3, Task4도 마찬가지로 ThreadC에서 동시에 수행할 수 있다. (여기서 동시에 수행한다는 것은 병렬성이 아니라 동시성을 말하는 것임)
따라서, 스레드와 달리 코루틴은 하나의 스레드 안에서 Object 단위(개발자가 정한 단위)로 수행되므로 컨텍스트 스위칭 비용이 들지 않는다.
4. asyncio
이제 동시성 코드를 작성하는 방법에 대해 알아보자. `asyncio`는 파이썬에서 async
/await
구문을 사용해 동시성 코드를 작성할 수 있게 해주는 라이브러리이다. 비동기 함수를 만드려면 일반 함수에 async
를 붙여 코루틴으로 등록한다.
위에서 작성했던 `working()` 함수를 코루틴으로 사용하려면 다음과 같이 작성할 수 있다. 단일 스레드로 동작하지만, 멀티 스레드를 사용하는 것만큼의 시간 효율을 만들어낸다.
import time
import asyncio
# I/O 작업을 하는 코루틴 등록
async def working():
await asyncio.sleep(3)
async def start():
request = [working() for _ in range(2)]
await asyncio.gather(*request)
st = time.time()
asyncio.run(start())
en = time.time()
print(f"coroutine => {en - st:.4f}s")
# coroutine => 3.0026s
- 함수에
async
키워드를 붙여 코루틴을 만든다. - 코루틴 안에서 다른 코루틴을 호출할 때는
await
키워드를 붙여 호출해야 한다.- 코루틴 수행 중 await을 만나면 await를 호출한 코루틴이 종료될 때까지 기다리지 않는고 다음 작업을 실행한다. 이러한 방식을 *논블로킹(non-blocking)이라 한다.
* 논블로킹(non-blocking) ?
A함수가 B함수를 호출해도 제어권은 그대로 자신이 가지고 있는 방식. A함수는 계속 제어권을 가지고 있기 때문에 B함수를 호출한 이후에도 B함수를 기다리지 않고 자신의 코드를 계속 실행한다.
Q. 논블로킹과 비동기와의 차이 ?
- 동기/비동기 - 작업을 순차적으로 수행하는 지의 여부에 대한 관점으로 나눈다.
- 블로킹/논블로킹 - 제어권을 넘기는 여부에 대한 관점으로 나눈다.
따라서 논블로킹과 비동기가 기다리지 않고 실행시킨다는 의미로 유사하지만, 관점에 따라 구분할 수 있는 용어라고 생각하면 된다.
비동기는 작업이 완료되기를 기다리지 않기 때문에 작업이 완료되지 않아도 다른 작업을 할 수 있고, 논블로킹은 작업의 흐름을 막지 않아 작업이 완료되지 않아도 다른 작업을 할 수 있다.
Awaitable 객체
`await` 키워드의 뒤에는 Awaitable한 객체가 올 수 있다. Awaitable 객체로는 Coroutine, Future, Task가 있다.
Future Object (퓨처)
작업의 실행 상태 및 결과를 저장하는 객체
- 실행 상태 : 해당 작업이 진행 중인지, 취소 되었는지, 종료 되었는지 3가지 상태 중 하나를 나타낸다.
- PENDING, CANCELLED, FINISHED
- 예외 발생 시에도 FINISHED 상태가 된다.
- 실행 결과 : 해당 작업의 결과 값 혹은 그 작업을 진행하면서 발생한 예외 객체
Task Object (태스크)
퓨처(Future)를 상속하는 클래스. 퓨처 객체와 마찬가지로 작업의 실행 상태 및 결과를 저장하고, 더하여 그 작업의 실행을 개시하는 역할도 한다.
- 태스크 객체는 생성될 때 인자로 코루틴 객체를 넘겨받는다.
asyncio.run()
orasyncio.create_task()
함수의 인자로 코루틴 객체를 넘겨 태스크 객체를 생성한다.
- 코루틴 객체를 가지는 특별한 종류의 퓨처 객체라고 볼 수 있다.
- 코루틴을 태스크 객체로 감싸고 실행을 예약한다.
- 태스크 객체는 생성되는 즉시 현재 스레드에 설정되어 있는 이벤트 루프에게 자신의 코루틴을 실행 예약한다. (이를 ’코루틴이 태스크로서 실행되도록 이벤트 루프에 예약을 건다.’고 말한다)
특징 | Coroutine 객체 | Future 객체 | Task 객체 |
비동기 함수 호출 시 생성 및 반환 | O | X | X |
실행 상태 및 결과 저장 | X | O | O |
생성 시 작업 실행 | X | X | O (이벤트 루프에 실행 예약) |
코루틴은 태스크 객체로 감싸져 이벤르 루프에 의해 실행된다.
태스크 객체는 I/O 관련 코루틴을 await
하는 코드를 마주치면, 자신의 실행을 중단하고 이벤트 루프에게 제어를 넘긴다. 이벤트 루프는 예약된 태스크들 중 우선순위가 높은 것을 적절히 선택하여 실행시키고, 시간이 흘러 이전에 중단되었던 태스크가 다시 실행할 수 있는 상태가 되면 이 태스크는 다시 이벤트 루프에 실행을 예약한다. 그럼 다시 선택을 받아 실행할 수 있는 상태가 된다.
코루틴을 호출해 코루틴 객체가 생성/반환됐다고 해서 해당 코루틴이 바로 실행되는 것은 아니다! 태스크 객체로 감싸지고, 이벤트 루프에 예약되어 결국 실행되는 것이다.
asnycio 라이브러리 용어
Coroutines and Tasks
This section outlines high-level asyncio APIs to work with coroutines and Tasks. Coroutines, Awaitables, Creating Tasks, Task Cancellation, Task Groups, Sleeping, Running Tasks Concurrently, Eager ...
docs.python.org
- asyncio.run(coro, *, debug=None, loop_factory=None)
- 코루틴을 실행하고 결과를 반환한다.
- 동기 함수에서 코루틴 함수를 호출하려면 `asyncio.run()`으로 호출해야 한다. (코루틴 첫 진입을 위해 사용)
- 새로운 이벤트 루프를 생성한다.
- asyncio.create_task(coro, *, name=None, context=None)
- 코루틴 여러 개를 실행시키기 위한 함수 (한 번에 여러개가 아니라, 여러 코루틴을 하나씩 호출할 수 있다)
- 인자로 코루틴을 넘겨, 코루틴을 감싼 태스크 객체를 반환한다. 해당 태스크는 이벤트 루프에 실행을 예약한다.
- name으로 태스크의 이름을 설정할 수 있다.
- 해당 함수를 호출했을 때, 실행 중인 이벤트 루프가 없으면 예외가 발생한다.
- asyncio.gather(*aws, return_exceptions=False)
- 인자로 여러 개의 Awaitable 객체를 받을 수 있는데, 만약 코루틴 객체를 받으면 이는 자동으로 이벤트 루프에 실행을 예약한다.
- 모든 퓨처 객체(태스크 객체 포함)들이 완료 상태가 될 때까지 기다린다.
- 모든 코루틴 함수가 성공한다면 등록된 순서대로 결과값이 반환
return_exceptions
옵션이 False면 첫 번째 발생한 예외가 그 뒤의 태스크에 전파된다. 실행중인 코루틴들은 취소되지 않고 계속 실행된다.return_exceptions
옵션이 True면 예외도 결과로 처리되고, 결과 리스트에 포함된다.
- asyncio.wait_for(aw, timeout)
- 코루틴 실행에서 타임아웃을 설정할 수 있는 함수
- aw가 코루틴이면 자동으로 이벤트 루프에 예약된다.
- 타임아웃이 넘어가면 실행중인 퓨처가 취소될 때까지 대기하기 때문에, 총 대기 시간은 타임아웃으로 설정한 시간을 초과할 수 있다.
- `asyncio.wait()`은 타임아웃이 발생해도 퓨처를 취소하지 않는다.
- aiohttp
- asyncio를 위한 http 서버/클라이언트 요청을 수행하는 라이브러리 (http 통신 비동기처리)
- 효율적인 I/O 처리와 높은 동시성 처리가 가능하다.
# https://docs.aiohttp.org/en/stable
import aiohttp
import asyncio
async defmain():
async with aiohttp.ClientSession() as session:
async with session.get('http://python.org') as response:
print("Status:", response.status)
print("Content-type:", response.headers['content-type'])
html = await response.text()
print("Body:", html[:15],"...")
asyncio.run(main())
ClientSession
- ClientSession은 커넥션 풀링의 이점을 얻기 위해 서버의 생명 주기 동안 한 번만 생성하여 사용할 수 있다. 매 요청마다 새 연결을 설정하는 오버헤드를 줄일 수 있다.
- 기본적으로 쿠키를 내부적으로 저장하고 있어, 한 도메인에 여러 요청을 보낼 때 자동으로 쿠키를 처리한다.
session.get() as response
에서 response는 ClientResponse 객체로, 응답 결과이다.
5. 실습
이제 파이썬 코루틴을 이용해 비동기적인 I/O 요청을 진행하는 실습을 진행해보려고 한다.
네이버페이 증권 - ‘많이 본 뉴스’의 헤드라인 조회
5-1. Controller
@AsyncTestApi.route('', methods=['GET'])
class ProxyTest(Resource):
def get(self):
message = MessageDto()
asyncTestService = AsyncTestService()
results = asyncio.run(asyncTestService.start_test()) # 코루틴 실행
message.setData(results)
message.setStatus(HTTPStatus.OK)
message.setMessage("success")
return message.__dict__, message.statusCode
- 서브루틴에서 코루틴을 실행시켜야 하므로 `asyncio.run()`으로 `start_test()`를 실행한다.
5-2. Service
NAVER_NEWS_REQUEST_URL = "https://finance.naver.com/news/news_list.naver"
REQUEST_PAGE_SIZE = 5 # 총 5페이지 조회
TOTAL_REQUEST_TIMEOUT_SIZE = 15 # timeout 시간 설정
CONTENTS_SIZE = 25
class AsyncTestService():
async def get_page_response(self, pageIndex):
response = None
subjects = []
headers = {"user-agent": UserAgent().random}
params = {
'mode': 'RANK',
'page': pageIndex+1
}
try:
# 페이지 요청
async with aiohttp.ClientSession() as session:
async with session.get(
url=NAVER_NEWS_REQUEST_URL,
headers=headers,
params=params) as response:
html = await response.text()
except asyncio.TimeoutError:
print("connection time out")
try:
# 헤드라인 추출
dom = BeautifulSoup(response, "html.parser")
articles = dom.select("#contentarea_left > div.hotNewsList._replaceNewsLink > ul > li > ul.simpleNewsList > li > a")
for idx, article in enumerate(articles):
subjects.append(f"{(pageIndex * CONTENTS_SIZE) + idx+1} : {article.string}")
except (KeyError, AttributeError, UnboundLocalError, TypeError):
print("response attribute error")
return subjects
async def start_test(self):
req = [self.get_page_response(i) for i in range(REQUEST_PAGE_SIZE)]
tasks = asyncio.gather(*req)
results = []
try:
# 비동기 요청
await asyncio.wait_for(tasks, timeout=TOTAL_REQUEST_TIMEOUT_SIZE)
except asyncio.TimeoutError:
raise TimeoutError("request time out")
for result in tasks.result():
results.extend(result)
return results
- 총 REQUEST_PAGE_SIZE 만큼 페이지를 요청하므로 코루틴으로 등록해 실행한다.
- start_test()
- 여러 페이지를 비동기적으로 요청해야 하므로
asyncio.gather()
를 이용해 태스크를 생성한다. await asyncio.wait_for()
로 이벤트 루프에 태스크 실행을 예약하고, 총 타임아웃 설정을 한다.
- 여러 페이지를 비동기적으로 요청해야 하므로
- get_page_response()
- `aiohttp`를 이용해 네이버 증권 페이지를 비동기 요청한다.
- 응답 결과를
BeautifulSoup
객체로 만들고, 헤드라인을 추출해 반환한다.
5-3. 실행 결과 - 포스트맨
비동기로 동작되는지도 확인해보자. 위에서 작성한 `get_page_response()`에 aiohttp로 페이지를 요청 및 응답할 때 print문을 찍어봤다.
async def get_page_response(self, pageIndex):
...
try:
print("=== pageIndex : " + str(pageIndex) + " started ===")
async with aiohttp.ClientSession() as session:
async with session.get(
url=NAVER_NEWS_REQUEST_URL,
headers=headers,
params=params) as response:
html = await response.text()
print("=== pageIndex : " + str(pageIndex) + " finished ===")
...
요청에 대한 응답이 오기전에 페이지를 요청하는 것을 확인할 수 있다.
만약 동기적 요청(`requests.get()`)으로 코드를 수정한다면, 요청에 대한 응답이 온 후에 다음 요청을 보내는 것을 확인할 수 있다.
async def get_page_response(self, pageIndex):
...
try:
print("=== pageIndex : " + str(pageIndex) + " started ===")
response = requests.get(
url=NAVER_NEWS_REQUEST_URL,
headers=headers,
params=params
)
html = response.text
print("=== pageIndex : " + str(pageIndex) + " finished ===")
...
6. 마무리
처음 프로젝트에 asyncio를 적용했을 때, requests을 사용해 HTTP 요청을 보냈지만 비동기적으로 호출되지 않는 문제가 있었다. 확인해보니 requests는 기본적으로 동기 방식으로 작동하는 라이브러리라 비동기적으로 호출되지 않는 것이었다. HTTP 통신 부분이 비동기로 걸리지 않아 발생하는 문제임을 알고, 다시 찾아보니 asyncio에서 HTTP 요청을 수행하기 위해 제공하는 aiohttp 라이브러리가 있었고, 이를 사용하니 의도한 대로 실행되었다. 이 과정을 통해 asyncio는 awaitable한 객체만을 비동기로 실행시킨다는 것 또한 알게 되었다. 이처럼 GIL부터 코루틴 등의 개념뿐만 아니라 직접 프로젝트에 적용하면서 겪은 시행착오들을 흐름대로 정리하려다보니 생각보다 추가 학습이 필요한 부분이 많았다. 이번 경험을 통해 이전보다 응답 시간이나 효율성 측면에서 개선된 코드를 짤 수 있을 것이다.
참고자료 😃
https://docs.python.org/ko/3/library/asyncio-task.html
https://docs.aiohttp.org/en/stable/
https://velog.io/@jaebig/python-동시성-관리-2-GILGlobal-Interpreter-Lock
https://velog.io/@jaebig/python-동시성-관리-3-코루틴Coroutine
https://it-eldorado.tistory.com/159?category=749661
https://velog.io/@nittre/블로킹-Vs.-논블로킹-동기-Vs.-비동기
https://inpa.tistory.com/entry/👩💻-동기비동기-블로킹논블로킹-개념-정리
'Python' 카테고리의 다른 글
[Python] SQLAlchemy 트랜잭션 설계 (+ decorator) (0) | 2024.06.24 |
---|---|
[Python] SQLAlchemy 개념 및 설정 방법 (0) | 2024.06.05 |
[Python] CGI & WSGI & ASGI (0) | 2024.04.16 |
[Python] Django & Flask & FastAPI (0) | 2024.04.15 |