GIL และ Threading
Global Interpreter Lock
หัวข้อที่มีชื่อว่า “Global Interpreter Lock”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 buildGIL ปกป้องอะไรและไม่ปกป้องอะไร
หัวข้อที่มีชื่อว่า “GIL ปกป้องอะไรและไม่ปกป้องอะไร”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 น่าสนใจกว่านั้น — วัดไว้ด้านล่าง
วัด race ของ += อย่างซื่อสัตย์
หัวข้อที่มีชื่อว่า “วัด race ของ += อย่างซื่อสัตย์”Demo แบบตำราคือ 8 เธรดแต่ละตัวทำ counter += 1 สองล้านครั้งโดยไม่มี lock คาดว่าค่าสุดท้ายจะต่ำกว่าที่ควร ลองทดลองตามนั้นเป๊ะๆ บน python3.14 รวมถึงบีบ switch interval ของ GIL ให้ต่ำสุดเพื่อบังคับให้ context switch บ่อยกว่า default มาก:
import sysimport 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_000NUM_THREADS = 8threads = [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: 4000000actual: 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 threadingimport 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 นั้นปลอดภัย
C extension ปล่อย GIL ได้เองอย่างชัดเจน
หัวข้อที่มีชื่อว่า “C extension ปล่อย GIL ได้เองอย่างชัดเจน”โค้ดที่ implement ด้วย C (ส่วนใหญ่ของ standard library ที่ performance-sensitive บวก compiled third-party library ส่วนใหญ่) สามารถปล่อย GIL อย่างชัดเจนรอบ operation ที่ทำงานนานและไม่แตะ Python object — hashlib, zlib, และ array operation ของ NumPy ทำแบบนี้ทั้งหมด นั่นหมายความว่าเธรดที่เรียกฟังก์ชันเหล่านี้ รันแบบ parallel จริงได้ ต่างจาก pure-Python loop:
import hashlibimport threadingimport 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 แบบนี้เลย
CPU-bound threads: ไม่เร็วขึ้น
หัวข้อที่มีชื่อว่า “CPU-bound threads: ไม่เร็วขึ้น”import threadingimport time
def count_down(n: int) -> None: while n > 0: n -= 1
N = 50_000_000
# Single threadstart = time.perf_counter()count_down(N)single = time.perf_counter() - start
# Two threads — same total work split across threadsstart = 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 build วัดผลจริง
หัวข้อที่มีชื่อว่า “Free-threaded build วัดผลจริง”คำกล่าวอ้าง “free-threaded Python ทำให้เธรดที่เป็น CPU-bound รันแบบ parallel ได้” พูดง่ายแต่ผิดในรายละเอียดได้ง่าย ดังนั้นนี่คือ benchmark count_down เดียวกันเป๊ะที่รันทั้งบน default GIL build และ python3.14t ติดกันบนเครื่องเดียว:
$ python3.14 cpu_bench.py # default build, GIL enabledgil_enabled=Truesingle thread: 0.43stwo threads: 0.41sspeedup: 1.04x
$ uv run --python 3.14t python cpu_bench.py # free-threaded buildgil_enabled=Falsesingle thread: 0.42stwo threads: 0.21sspeedup: 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 หรือยังก่อนเปลี่ยนโปรเจกต์จริง
I/O-bound threads: concurrency จริง
หัวข้อที่มีชื่อว่า “I/O-bound threads: concurrency จริง”เมื่อเธรด block บน I/O, GIL ถูกปล่อย ทำให้เธรดอื่นรัน Python code ได้ นี่คือเหตุผลที่ threading ยังมีประโยชน์สำหรับงาน I/O-bound เช่น HTTP request หรือ file read
import threadingimport 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()threading.Thread — พื้นฐาน
หัวข้อที่มีชื่อว่า “threading.Thread — พื้นฐาน”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 finishprint("background thread done")t.join() block เธรดที่เรียกจนกว่า t จะเสร็จ
ถ้าไม่มี join() main thread อาจ exit ทั้งที่ background thread ยังรันอยู่
Daemon threads
หัวข้อที่มีชื่อว่า “Daemon threads”Daemon thread ไม่ทำให้ process มีชีวิตอยู่ต่อ — เมื่อทุกเธรดที่ไม่ใช่ daemon (รวม main) เสร็จแล้ว Python จะ exit ฆ่า daemon thread ที่กำลังรันอยู่กลางคันโดยไม่รัน finally block ใช้ daemon thread สำหรับงาน background ที่ยอมรับได้ถ้าจะหายไป (heartbeat, cache-warming loop); อย่าใช้กับอะไรที่ต้อง cleanup ตัวเอง:
import threadingimport 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 setstop.set()Thread safety ด้วย threading.Lock
หัวข้อที่มีชื่อว่า “Thread safety ด้วย threading.Lock”Shared mutable state ต้องการ synchronization อย่างชัดเจนเมื่อมีจุดหยุดจริง (I/O, blocking call, หรือ section ที่ยาวพอให้ OS scheduler preempt ได้) แทรกอยู่ระหว่างการอ่านกับการเขียนที่ขึ้นกับมัน threading.Lock รับประกันว่ามีแค่เธรดเดียวรัน section ที่ lock ในเวลาใดเวลาหนึ่ง:
import threading
counter: int = 0lock = 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 หนึ่งไม่ใช่หลักฐานความถูกต้อง
queue.Queue เทียบกับ list ธรรมดาสำหรับข้อมูลข้ามเธรด
หัวข้อที่มีชื่อว่า “queue.Queue เทียบกับ list ธรรมดาสำหรับข้อมูลข้ามเธรด”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 queueimport 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]ThreadPoolExecutor — high-level API
หัวข้อที่มีชื่อว่า “ThreadPoolExecutor — high-level API”concurrent.futures.ThreadPoolExecutor จัดการ pool ของเธรดและคืน Future objects
map กับ submit/as_completed ต่างกันในจุดสำคัญ: map คืนผลลัพธ์ตามลำดับ inputเสมอ ในขณะที่ as_completed คืนตามลำดับ completion:
from concurrent.futures import ThreadPoolExecutor, as_completedimport 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 orderfuture.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')เชื่อม threads กับ asyncio: run_in_executor
หัวข้อที่มีชื่อว่า “เชื่อม threads กับ asyncio: run_in_executor”เมื่อต้องเรียก blocking library จาก async context โดยไม่ block event loop:
import asynciofrom concurrent.futures import ThreadPoolExecutorimport 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
Depth demo: ThreadPoolExecutor, daemon threads และ Queue
หัวข้อที่มีชื่อว่า “Depth demo: ThreadPoolExecutor, daemon threads และ Queue”คำกล่าวอ้างเชิงเวลา (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_completedimport queueimport threadingimport 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()Loading Python runtime (first run only)…
เมื่อไหร่ควรใช้ threads เทียบกับ asyncio
หัวข้อที่มีชื่อว่า “เมื่อไหร่ควรใช้ threads เทียบกับ asyncio”| เกณฑ์ | ใช้ 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 | ไม่ | ใช่ |
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”ลองนึกภาพ 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 | reference counting ง่ายและปลอดภัย ไม่ต้อง lock รายตัว object | เธรดที่เป็น CPU-bound ไม่เคยรัน Python bytecode แบบ parallel |
threading สำหรับ I/O | shared-memory model ง่าย ไม่ต้อง serialize | ยังต้องใช้ Lock รอบ check-then-act ใดๆ ที่คร่อม blocking call |
| Free-threaded 3.14t | speedup แบบ parallel จริงสำหรับเธรด pure-Python ที่เป็น CPU-bound | binary แยกต่างหาก; ระบบนิเวศ C extension ยังตามไม่ทันทั้งหมด |
ThreadPoolExecutor | API ระดับสูงที่สะอาด เชื่อมกับ asyncio ได้ | map กับ submit/as_completed รับประกันลำดับต่างกัน — สับสนง่าย |
| Daemon threads | process 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
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”ก่อนเชื่อโค้ด 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 ไม่ใช่แค่การลบตอนสุดท้าย