ข้ามไปยังเนื้อหา

GIL และ Threading

CPython (interpreter มาตรฐานของ Python) มี mutex ตัวหนึ่งเรียกว่า Global Interpreter Lock (GIL) GIL รับประกันว่า มีแค่เธรดเดียวที่รัน Python bytecode ในเวลาใดเวลาหนึ่ง — แม้บนเครื่องที่มีหลาย CPU core

GIL มีเหตุผลเชิงปฏิบัติ: การจัดการหน่วยความจำของ CPython (reference counting) ไม่ thread-safe แทนที่จะใส่ lock ละเอียดในทุก object นักพัฒนา CPython เลือกวิธีง่ายกว่าคือ lock กลางตัวเดียว

ผลที่ตามมา:

  • โค้ด Python ที่เป็น CPU-bound ที่รันในหลายเธรดไม่ได้รันแบบ parallel — threads ผลัดกันทำงาน
  • เธรดที่เป็น I/O-bound ได้ประโยชน์ จาก threading เพราะ GIL ถูกปล่อยขณะเธรดรอ OS I/O call ให้เสร็จ

เช็คว่า interpreter ที่คุณรันมี GIL หรือไม่ด้วย sys._is_gil_enabled():

import sys
print(sys._is_gil_enabled()) # True on the default python3.14 build

GIL รับประกัน ความปลอดภัยของหน่วยความจำ: ไม่มีสองเธรดที่ทำให้โครงสร้าง internal object ของ CPython เสียหายจากการเขียนพร้อมกันในเสี้ยววินาทีเดียว มันไม่รับประกันว่าลำดับของ Python statement จะรันเป็นหน่วยเดียวแบบ atomic statement อย่าง counter += 1 compile เป็น bytecode หลายตัว ไม่ใช่ตัวเดียว:

import dis
def bump():
global counter
counter += 1
dis.dis(bump)
6 RESUME 0
8 LOAD_GLOBAL 0 (counter)
LOAD_SMALL_INT 1
BINARY_OP 13 (+=)
STORE_GLOBAL 0 (counter)
LOAD_CONST 1 (None)
RETURN_VALUE

สี่ opcode: อ่าน counter, push 1, บวก, เขียน counter กลับ ในทางทฤษฎี GIL สลับไปเธรดอื่นระหว่างสองตัวไหนก็ได้ ซึ่งเป็นเหตุผลตำราเรียนบอกว่า += บน shared state “ไม่ atomic” และ “ต้องใช้ lock” สิ่งที่เกิดขึ้นจริงบน 3.14 น่าสนใจกว่านั้น — วัดไว้ด้านล่าง

Demo แบบตำราคือ 8 เธรดแต่ละตัวทำ counter += 1 สองล้านครั้งโดยไม่มี lock คาดว่าค่าสุดท้ายจะต่ำกว่าที่ควร ลองทดลองตามนั้นเป๊ะๆ บน python3.14 รวมถึงบีบ switch interval ของ GIL ให้ต่ำสุดเพื่อบังคับให้ context switch บ่อยกว่า default มาก:

import sys
import threading
sys.setswitchinterval(0.0000001) # force much more frequent GIL switches than the 5ms default
counter = 0
def increment(n: int) -> None:
global counter
for _ in range(n):
counter += 1
N_PER_THREAD = 500_000
NUM_THREADS = 8
threads = [threading.Thread(target=increment, args=(N_PER_THREAD,)) for _ in range(NUM_THREADS)]
for t in threads: t.start()
for t in threads: t.join()
print(f"expected: {N_PER_THREAD * NUM_THREADS}")
print(f"actual: {counter}")
expected: 4000000
actual: 4000000

ไม่มี update ที่หายไปเลย — แม้จะบีบ switch interval แบบสุดโต่ง เหตุผลคือ “eval breaker” ของ CPython (ตัวเช็คว่า “ควรให้เธรดอื่นรันตอนนี้ไหม”) ทำงานเฉพาะจุด safe point เฉพาะใน bytecode dispatch loop — หลักๆ คือ loop back-edge และ call — ไม่ใช่ระหว่างทุก instruction counter += 1 เปล่าๆ ใน for loop ไม่มี safe point แบบนั้นระหว่างสี่ opcode ของมัน มีแค่ตอนขึ้น iteration ถัดไป ดังนั้น pattern นี้จึงเป็น atomic โดยพฤตินัยใน CPython วันนี้ ล้วนๆ จากรายละเอียด implementation ว่า interpreter เช็คจุดสลับตรงไหน — ไม่ใช่เพราะภาษารับประกันไว้

ความต่างนี้สำคัญ: เปลี่ยน pattern ให้มีจุดหยุดจริงๆ อยู่ระหว่างการอ่านกับการเขียน — เช่น blocking call — race จะกลับมาทันที:

import threading
import time
class Account:
def __init__(self, balance: int) -> None:
self.balance = balance
def withdraw_unsafe(acct: Account, amount: int) -> None:
balance = acct.balance # read
time.sleep(0.01) # GIL released here — another thread can run
acct.balance = balance - amount # write, based on a now-stale read
acct = Account(balance=100)
threads = [threading.Thread(target=withdraw_unsafe, args=(acct, 80)) for _ in range(2)]
for t in threads: t.start()
for t in threads: t.join()
print(acct.balance)
20

ทั้งสองเธรดอ่าน balance=100 ก่อนที่ตัวใดตัวหนึ่งจะเขียน — lost update ที่ reproduce ได้เสมอ เพราะ time.sleep ปล่อย GIL จริงๆ กลางลำดับ บทเรียนไม่ใช่ “+= ปลอดภัย” แต่คือ: GIL ป้องกัน memory corruption ไม่ใช่ logical race — และลำดับ check-then-act เฉพาะเจาะจงจะ race จริงหรือไม่ในทางปฏิบัติขึ้นกับว่ามีอะไรระหว่าง check กับ act ที่ปล่อย GIL ได้หรือไม่ ซึ่งเป็นรายละเอียด implementation ที่ไม่ควรพึ่งพาเลย ป้องกัน read-modify-write จริงๆ ด้วย Lock เสมอ อย่าเชื่อ demo ที่ “ไม่มี update หายไป” ว่าเป็นหลักฐานว่า pattern นั้นปลอดภัย

โค้ดที่ implement ด้วย C (ส่วนใหญ่ของ standard library ที่ performance-sensitive บวก compiled third-party library ส่วนใหญ่) สามารถปล่อย GIL อย่างชัดเจนรอบ operation ที่ทำงานนานและไม่แตะ Python object — hashlib, zlib, และ array operation ของ NumPy ทำแบบนี้ทั้งหมด นั่นหมายความว่าเธรดที่เรียกฟังก์ชันเหล่านี้ รันแบบ parallel จริงได้ ต่างจาก pure-Python loop:

import hashlib
import threading
import time
DATA = b"x" * 50_000_000
def hash_chunk() -> None:
hashlib.sha256(DATA).hexdigest() # implemented in C; releases the GIL during the hashing loop
start = time.perf_counter()
hash_chunk(); hash_chunk()
single = time.perf_counter() - start
start = time.perf_counter()
threads = [threading.Thread(target=hash_chunk) for _ in range(2)]
for t in threads: t.start()
for t in threads: t.join()
threaded = time.perf_counter() - start
print(f"one thread, two calls: {single:.2f}s")
print(f"two threads, one each: {threaded:.2f}s")

วัดบนเครื่องนี้: 0.04s สำหรับสอง call แบบ sequential เทียบกับ 0.02s เมื่อรันบนสองเธรด — ประมาณ 2 เท่า เพราะ hashlib.sha256 ปล่อย GIL ขณะทำงาน loop เดียวกันที่เขียนด้วย pure Python (เช่นตัวอย่าง count_down ด้านบน) จะไม่มี speedup แบบนี้เลย

import threading
import time
def count_down(n: int) -> None:
while n > 0:
n -= 1
N = 50_000_000
# Single thread
start = time.perf_counter()
count_down(N)
single = time.perf_counter() - start
# Two threads — same total work split across threads
start = time.perf_counter()
t1 = threading.Thread(target=count_down, args=(N // 2,))
t2 = threading.Thread(target=count_down, args=(N // 2,))
t1.start(); t2.start()
t1.join(); t2.join()
threaded = time.perf_counter() - start
print(f"Single thread: {single:.2f}s")
print(f"Two threads: {threaded:.2f}s")
# Measured three times on this machine (python3.14): 0.51s vs 0.52s, 0.51s vs
# 0.53s, 0.51s vs 0.51s — two threads are not faster (same total work, same
# time) and can be marginally slower from GIL-switch and context-switch
# overhead. Numbers are machine-dependent.

คำกล่าวอ้าง “free-threaded Python ทำให้เธรดที่เป็น CPU-bound รันแบบ parallel ได้” พูดง่ายแต่ผิดในรายละเอียดได้ง่าย ดังนั้นนี่คือ benchmark count_down เดียวกันเป๊ะที่รันทั้งบน default GIL build และ python3.14t ติดกันบนเครื่องเดียว:

Terminal window
$ python3.14 cpu_bench.py # default build, GIL enabled
gil_enabled=True
single thread: 0.43s
two threads: 0.41s
speedup: 1.04x
$ uv run --python 3.14t python cpu_bench.py # free-threaded build
gil_enabled=False
single thread: 0.42s
two threads: 0.21s
speedup: 1.96x

บน default build สองเธรดแทบไม่เร็วขึ้นเลย (1.04x — ระดับ noise ตรงกับผล “สองเธรดไม่เร็วขึ้น” ด้านบน) บน free-threaded build sys._is_gil_enabled() รายงาน False ถูกต้อง และสองเธรดเข้าใกล้ 2x ในอุดมคติสำหรับ workload แบบ embarrassingly parallel นี้ ประสิทธิภาพ single-thread แยกไม่ออกทางสถิติระหว่างสอง build จากการวัดนี้ (0.43s เทียบกับ 0.42s) — “5-10% single-thread overhead” ที่มักถูกอ้างถึงสำหรับ free-threaded build ไม่ปรากฏในการวัดนี้ วัด workload ของคุณเองก่อนสรุปไปทางใดทางหนึ่ง และเช็คว่า C-extension dependency ของคุณ opt in free-threading support หรือยังก่อนเปลี่ยนโปรเจกต์จริง

เมื่อเธรด block บน I/O, GIL ถูกปล่อย ทำให้เธรดอื่นรัน Python code ได้ นี่คือเหตุผลที่ threading ยังมีประโยชน์สำหรับงาน I/O-bound เช่น HTTP request หรือ file read

import threading
import time
def simulate_io(label: str, delay: float) -> None:
time.sleep(delay) # releases the GIL — other threads run freely
print(f"{label} complete after {delay}s")
# Sequential: ~0.6 s total
# Threaded: ~0.2 s total (all three sleep concurrently)
threads = [
threading.Thread(target=simulate_io, args=(f"task-{i}", 0.2))
for i in range(3)
]
for t in threads: t.start()
for t in threads: t.join()
import threading
def worker(name: str, count: int) -> None:
for i in range(count):
print(f"[{name}] step {i}")
t = threading.Thread(target=worker, args=("background", 3))
t.start()
print("main thread continues here")
t.join() # wait for the thread to finish
print("background thread done")

t.join() block เธรดที่เรียกจนกว่า t จะเสร็จ ถ้าไม่มี join() main thread อาจ exit ทั้งที่ background thread ยังรันอยู่

Daemon thread ไม่ทำให้ process มีชีวิตอยู่ต่อ — เมื่อทุกเธรดที่ไม่ใช่ daemon (รวม main) เสร็จแล้ว Python จะ exit ฆ่า daemon thread ที่กำลังรันอยู่กลางคันโดยไม่รัน finally block ใช้ daemon thread สำหรับงาน background ที่ยอมรับได้ถ้าจะหายไป (heartbeat, cache-warming loop); อย่าใช้กับอะไรที่ต้อง cleanup ตัวเอง:

import threading
import time
def background_ticker(stop: threading.Event) -> None:
while not stop.is_set():
time.sleep(0.01)
stop = threading.Event()
t = threading.Thread(target=background_ticker, args=(stop,), daemon=True)
t.start()
print(f"daemon thread running: {t.is_alive()}, daemon={t.daemon}")
# no t.join() here -- a daemon thread is killed automatically when the main
# thread exits; a non-daemon thread in its place would keep the process alive
# forever, since background_ticker only stops when `stop` is set
stop.set()

Shared mutable state ต้องการ synchronization อย่างชัดเจนเมื่อมีจุดหยุดจริง (I/O, blocking call, หรือ section ที่ยาวพอให้ OS scheduler preempt ได้) แทรกอยู่ระหว่างการอ่านกับการเขียนที่ขึ้นกับมัน threading.Lock รับประกันว่ามีแค่เธรดเดียวรัน section ที่ lock ในเวลาใดเวลาหนึ่ง:

import threading
counter: int = 0
lock = threading.Lock()
def increment(n: int) -> None:
global counter
for _ in range(n):
with lock:
counter += 1
threads = [threading.Thread(target=increment, args=(100_000,)) for _ in range(5)]
for t in threads: t.start()
for t in threads: t.join()
print(counter) # reliably 500_000

ใช้ Lock เมื่อไหร่ก็ตามที่ race condition เป็นไปได้ในทางทฤษฎี — check-then-act, read-modify-write ที่คร่อม call ใดๆ ที่อาจ block — แทนที่จะพึ่งว่า run ครั้งใดครั้งหนึ่งบังเอิญไม่แสดง corruption อย่างที่วัดไว้ด้านบน การไม่มี data race ใน run หนึ่งไม่ใช่หลักฐานความถูกต้อง

list ธรรมดาไม่ปลอดภัยที่จะแชร์ระหว่างเธรดสำหรับอะไรที่เกินกว่าที่ GIL บังเอิญทำให้ atomic (append/pop บน list แต่ละตัวปลอดภัย แต่ check-then-act แบบ “ถ้าไม่ว่าง pop” ไม่ปลอดภัย) queue.Queue ถูกออกแบบมาสำหรับส่งต่อข้ามเธรดโดยเฉพาะ: put/get มี lock ภายในให้ และ get block จนกว่าจะมี item ซึ่ง pop(0) ของ list ทำไม่ได้เลยถ้าไม่เขียน sleep-and-retry loop เอง:

import queue
import threading
q: queue.Queue = queue.Queue()
def producer() -> None:
for i in range(5):
q.put(i)
q.put(None) # sentinel
def consumer(out: list) -> None:
while True:
item = q.get() # blocks until an item is available -- no busy-waiting
if item is None:
break
out.append(item)
out: list = []
p = threading.Thread(target=producer)
c = threading.Thread(target=consumer, args=(out,))
p.start(); c.start()
p.join(); c.join()
print(out) # [0, 1, 2, 3, 4]

concurrent.futures.ThreadPoolExecutor จัดการ pool ของเธรดและคืน Future objects map กับ submit/as_completed ต่างกันในจุดสำคัญ: map คืนผลลัพธ์ตามลำดับ inputเสมอ ในขณะที่ as_completed คืนตามลำดับ completion:

from concurrent.futures import ThreadPoolExecutor, as_completed
import time
def work(label: str, delay: float) -> str:
time.sleep(delay)
return f"{label} done"
with ThreadPoolExecutor(max_workers=4) as pool:
print(list(pool.map(work, ["A", "B", "C"], [0.06, 0.02, 0.04])))
# ['A done', 'B done', 'C done'] -- input order, even though B and C finish first
with ThreadPoolExecutor(max_workers=4) as pool:
futures = {pool.submit(work, label, delay): label
for label, delay in [("A", 0.06), ("B", 0.02), ("C", 0.04)]}
print([f.result() for f in as_completed(futures)])
# ['B done', 'C done', 'A done'] -- completion order

future.result() re-raise exception ที่ worker function raise ไว้ ที่จุดเรียก — เหมือนกับเรียกฟังก์ชันตรงๆ ลบไปหนึ่ง traceback frame:

from concurrent.futures import ThreadPoolExecutor
def boom() -> int:
raise ValueError("worker failed")
with ThreadPoolExecutor(max_workers=1) as pool:
fut = pool.submit(boom)
try:
fut.result()
except ValueError as e:
print(f"re-raised: {e!r}")
# re-raised: ValueError('worker failed')

เมื่อต้องเรียก blocking library จาก async context โดยไม่ block event loop:

import asyncio
from concurrent.futures import ThreadPoolExecutor
import time
def blocking_call(x: int) -> int:
time.sleep(0.1) # a blocking library — e.g. psycopg2, boto3 (sync)
return x * x
async def main() -> None:
loop = asyncio.get_running_loop()
with ThreadPoolExecutor(max_workers=4) as pool:
results = await asyncio.gather(
*[loop.run_in_executor(pool, blocking_call, i) for i in range(5)]
)
print(results)
asyncio.run(main())

asyncio.to_thread ที่อยู่ในบท Async / Await เป็น wrapper บางๆ ของการเรียกแบบนี้เป๊ะโดยใช้ executor default

คำกล่าวอ้างเชิงเวลา (speedup ของ CPU-bound, benchmark ของ free-threaded) ต้องการเธรดระดับ OS แบบ parallel จริง จึงเชื่อไม่ได้ใน browser sandbox ดังนั้นส่วนนั้นยังคงเป็นตัวอย่างแบบ fence ด้านบนให้รันในเครื่อง ส่วน demo ที่เน้นความถูกต้องด้านล่าง — ลำดับของ map เทียบ submit/as_completed, future.result() re-raise, flag ของ daemon thread, และการส่งต่อของ queue.Queue — ไม่ต้องพึ่ง parallelism จริง จึงรันเหมือนกันทั้งใน Pyodide และในเครื่อง:

from concurrent.futures import ThreadPoolExecutor, as_completed
import queue
import threading
import time
def work(label: str, delay: float) -> str:
time.sleep(delay)
return f"{label} done"
with ThreadPoolExecutor(max_workers=4) as pool:
print("map (input order):", list(pool.map(work, ["A", "B", "C"], [0.06, 0.02, 0.04])))
with ThreadPoolExecutor(max_workers=4) as pool:
futures = {pool.submit(work, label, delay): label
for label, delay in [("A", 0.06), ("B", 0.02), ("C", 0.04)]}
order = [fut.result() for fut in as_completed(futures)]
print("submit + as_completed (completion order):", order)
def boom() -> int:
raise ValueError("worker failed")
with ThreadPoolExecutor(max_workers=1) as pool:
fut = pool.submit(boom)
try:
fut.result()
except ValueError as e:
print(f"future.result() re-raises: {e!r}")
stop = threading.Event()
t = threading.Thread(target=lambda: stop.wait(), daemon=True)
t.start()
print(f"daemon thread: alive={t.is_alive()}, daemon={t.daemon}")
q: queue.Queue = queue.Queue()
def producer() -> None:
for i in range(5):
q.put(i)
q.put(None)
def consumer(out: list) -> None:
while True:
item = q.get()
if item is None:
break
out.append(item)
out: list = []
p = threading.Thread(target=producer)
c = threading.Thread(target=consumer, args=(out,))
p.start(); c.start()
p.join(); c.join()
print(f"queue.Queue producer/consumer result: {out}")
stop.set()
เกณฑ์ใช้ asyncioใช้ threading
I/O-bound, async-native libsใช่ไม่
I/O-bound, blocking libs (boto3, psycopg2)run_in_executorใช่
งาน CPU-boundไม่ไม่ — ใช้ multiprocessing หรือ free-threaded 3.14t
ต้องการ structured cancellationใช่ (TaskGroup)ยากกว่า
Legacy codebase แบบ synchronousไม่ใช่

ลองนึกภาพ GIL เป็นไม้ผลัดในการวิ่งผลัดหนึ่งอันในห้องที่เต็มไปด้วยนักวิ่ง (เธรด) เฉพาะคนที่ถือไม้ผลัดเท่านั้นที่แตะ Python object ที่แชร์บนโต๊ะได้ นักวิ่งวางไม้ผลัดโดยสมัครใจเมื่อเรียกอะไรที่ต้องรอ — I/O, time.sleep, ฟังก์ชัน C ที่ปล่อยมัน — และ CPython ยังบังคับส่งต่อเป็นระยะๆ เพื่อไม่ให้เธรดใดถือไม้ผลัดตลอดไปขณะคำนวณล้วนๆ ไม้ผลัดเองป้องกันแค่ไม่ให้นักวิ่งสองคนคว้า object เดียวกันพร้อมกัน (ความปลอดภัยของหน่วยความจำ) มันไม่ได้บอกอะไรเลยว่าลำดับการเคลื่อนไหวที่นักวิ่งคนหนึ่งทำขณะถือไม้ผลัด ข้ามหลายการส่งต่อ ยังรวมกันเป็นผลลัพธ์ที่ถูกต้องหรือไม่

flowchart LR
T1["Thread 1 holds GIL"] -->|"time.sleep() releases it"| T2["Thread 2 acquires GIL"]
T2 -->|"finishes I/O wait, re-acquires"| T1b["Thread 1 resumes"]
T2 -.->|"pure computation: no release until eval-breaker checkpoint"| T2
GIL เปลี่ยนมือตอน blocking call และ checkpoint เป็นระยะ — ไม่ใช่ระหว่างทุก bytecode
สิ่งที่ได้ประโยชน์ต้นทุน
GILreference counting ง่ายและปลอดภัย ไม่ต้อง lock รายตัว objectเธรดที่เป็น CPU-bound ไม่เคยรัน Python bytecode แบบ parallel
threading สำหรับ I/Oshared-memory model ง่าย ไม่ต้อง serializeยังต้องใช้ Lock รอบ check-then-act ใดๆ ที่คร่อม blocking call
Free-threaded 3.14tspeedup แบบ parallel จริงสำหรับเธรด pure-Python ที่เป็น CPU-boundbinary แยกต่างหาก; ระบบนิเวศ C extension ยังตามไม่ทันทั้งหมด
ThreadPoolExecutorAPI ระดับสูงที่สะอาด เชื่อมกับ asyncio ได้map กับ submit/as_completed รับประกันลำดับต่างกัน — สับสนง่าย
Daemon threadsprocess exit ได้ทันที ไม่มี background loop ค้างไม่รับประกัน cleanup — finally block ถูกข้ามได้เลย
  • “+= บน shared state race เสมอถ้าไม่มี lock” วัดไว้ด้านบนแล้ว: tight loop ของ counter += 1 ไม่มี update หายไปเลยแม้จะบีบ switch interval แบบสุดโต่ง เพราะ eval-breaker ของ CPython เช็คแค่ตรง loop/call boundary นั่นไม่ได้ทำให้มันปลอดภัย — มันหมายความว่าการไม่พบ race ไม่ใช่หลักฐานความถูกต้อง read-modify-write ใดๆ ที่มีจุดหยุดจริงคั่นกลาง (blocking call, I/O) จะ race ได้เสมอ
  • “GIL ทำให้ Python code ปลอดภัยแบบ single-threaded โดยอัตโนมัติ” มันป้องกัน memory corruption ไม่ใช่ logic error ลำดับ check-then-act ยังเป็น race ถ้ามีอะไรระหว่าง check กับ act ที่ปล่อยการควบคุมได้
  • “Free-threaded Python เป็นแค่ compiler flag” มันคือ build แยกต่างหากของ interpreter (python3.14t) ที่มี ABI ต่างกัน C extensions ต้องประกาศรองรับมันอย่างชัดเจน
  • “ThreadPoolExecutor.map กับ as_completed ทำงานเหมือนกัน” map รักษาลำดับ input เสมอไม่ว่า call ไหนจะเสร็จก่อน; as_completed คืน future ที่เสร็จก่อนก่อน ซึ่งต่างจากลำดับ submission ได้
  • “daemon thread เป็นแค่เธรดธรรมดาที่มี boolean flag เพื่อความสวยงาม” มันเปลี่ยนพฤติกรรมจริง: process จะไม่รอมัน และมันถูกฆ่ากลาง statement โดยไม่มี cleanup ตอน process exit

ก่อนเชื่อโค้ด threading ที่ AI สร้าง ตรวจสิ่งเหล่านี้:

  • มี shared counter หรือ accumulator ที่ถูกอัปเดตด้วย += (หรือ read-modify-write ใดๆ) จากหลายเธรดโดยไม่มี Lock โดยอ้างว่า “มันแค่ integer” หรือไม่? ความปลอดภัยของสิ่งนั้นขึ้นกับรายละเอียด implementation ที่เปลี่ยนได้ Lock แทบไม่มีต้นทุนตรงนี้
  • มี check-then-act (if queue: item = queue.pop(0), if not path.exists(): create(path)) ที่แชร์ข้ามเธรดโดยมี blocking call คั่นกลางหรือไม่?
  • มี background thread ที่สร้างด้วย daemon=True ทั้งที่จริงๆ ต้องทำ cleanup ให้เสร็จก่อน process exit หรือไม่?
  • โค้ดเรียก ThreadPoolExecutor.submit ใน loop แล้ว .result() แต่ละ future ทันทีตามลำดับ submission หรือไม่ — ซึ่งทำลายจุดประสงค์ทั้งหมดของการ submit แบบ concurrent และเทียบเท่ากับการเรียก map เฉยๆ?
  • multiprocessing/ProcessPoolExecutor ถูกป้อนด้วย lambda หรือ closure ที่ประกาศในเครื่องหรือไม่? ดูบท Multiprocessing — สิ่งนี้ fail ด้วย pickling error จริง ไม่ใช่แค่ warning

🐛 หา bug ในโค้ดนี้

class Account:
def __init__(self, balance: int) -> None:
self.balance = balance
def withdraw(acct: Account, amount: int) -> None:
if acct.balance >= amount:
time.sleep(0.01) # e.g. a "logging" or "audit" call before committing
acct.balance -= amount
acct = Account(balance=100)
threads = [threading.Thread(target=withdraw, args=(acct, 80)) for _ in range(2)]
for t in threads:
t.start()
for t in threads:
t.join()
print(acct.balance)
เฉลย

โค้ดนี้ดูเหมือนการเช็ค balance ก่อนถอนเงินตรงไปตรงมา และการอ่านผ่านๆ อาจคิดว่า GIL ทำให้ปลอดภัยเพราะ “มีแค่เธรดเดียวรัน Python ในเวลาหนึ่ง” แต่ time.sleep ระหว่าง check กับ write ปล่อย GIL ให้เธรดที่สองรัน if acct.balance >= amount กับ balance ที่ยังไม่ถูกแก้ ทั้งสอง check ผ่าน ทั้งสองการถอนถูกใช้ และ acct.balance จบที่ 20 แทนที่จะเป็นการปฏิเสธที่ถูกต้อง (-60) หรือผลลัพธ์แบบ serialize (20/reject) — ไม่ว่าทางไหน ทั้งสองเธรดเชื่อว่าตัวเองมีสิทธิ์ถอนจาก 100 เดียวกัน ทางแก้คือ threading.Lock ที่ครอบทั้งลำดับ check-then-act ไม่ใช่แค่การลบตอนสุดท้าย

ทำไมการเพิ่มเธรดถึงไม่เร่งความเร็วโค้ด Python ที่เป็น CPU-bound ใน CPython?
Python thread ปล่อย GIL เมื่อไหร่?
รันบน python3.14 (มี GIL) แม้บีบ switch interval ของ GIL ให้ต่ำมาก วัดผลได้อะไรจริง?
import threading

counter = 0

def increment(n):
    global counter
    for _ in range(n):
        counter += 1

threads = [threading.Thread(target=increment, args=(2_000_000,)) for _ in range(8)]
for t in threads: t.start()
for t in threads: t.join()

print(counter)
โค้ดนี้พิมพ์อะไร และเพราะอะไร -- ในเมื่อ tight loop ของ += ด้านบนไม่มี update หายไปเลย?
class Account:
    def __init__(self, balance):
        self.balance = balance

def withdraw(acct, amount):
    balance = acct.balance
    time.sleep(0.01)
    acct.balance = balance - amount

acct = Account(balance=100)
threads = [threading.Thread(target=withdraw, args=(acct, 80)) for _ in range(2)]
for t in threads: t.start()
for t in threads: t.join()

print(acct.balance)
ความต่างเชิงพฤติกรรมหลักระหว่าง ThreadPoolExecutor.map กับ submit + as_completed คืออะไร?
เกิดอะไรขึ้นกับ daemon thread เมื่อ main thread เสร็จและ process พร้อม exit?
ใน free-threaded build ของ Python 3.14 (python3.14t) sys._is_gil_enabled() รายงานอะไร และ benchmark CPU-bound ที่วัดจริงแสดงอะไร?