Python 內部如何運作:從程式碼到 Bytecode
Python ทำงานภายในอย่างไร: จากโค้ดสู่ Bytecode
CPython 會將 Python 程式碼依序 tokenize、parse、建立 AST 和 symbol table,再編譯成 bytecode,由堆疊式虛擬機器執行。
Python executes code 10x slower than C++ — ประโยคนี้ได้ยินกันบ่อย แต่ทำไมบริษัทระดับโลกยังใช้ Python รันระบบใหญ่ได้ คำตอบอยู่ที่ขั้นตอนเบื้องหลังที่ Python แปลงโค้ดของคุณเป็น bytecode ก่อนรัน บทความนี้จะพาคุณเดินตามกระบวนการนั้นทีละขั้น พร้อมโค้ดให้ลองรันเองใน VS Code
TL;DR: Python (CPython) ไม่ได้อ่านโค้ดแล้วรันทีละบรรทัดโดยตรง แต่แปลงโค้ดผ่าน 5 ขั้นตอน คือ tokenize, parse, สร้าง AST, สร้าง symbol table และคอมไพล์เป็น bytecode แล้วให้ Virtual Machine แบบ stack-based รันต่อ ตั้งแต่ Python 3.11 (ตุลาคม 2022) มี adaptive interpreter ที่ทำให้เร็วขึ้น 10-60% เมื่อเทียบกับ 3.10 คุณดู bytecode จริงได้ทันทีด้วยโมดูล dis
Key Facts ที่คนส่วนใหญ่ไม่รู้
- Python's name comes from Monty Python's Flying Circus, not the snake, decided by Guido van Rossum in 1991
- CPython compiles your code to bytecode with 163 opcodes before execution, not direct interpretation
- Python 3.11 released October 2022 runs 10-60% faster than 3.10 using a specialized adaptive interpreter
ทำไมต้องเข้าใจการทำงานภายในของ Python?
การเข้าใจว่า Python ทำงานอย่างไรช่วยให้คุณเขียนโค้ดที่เร็วขึ้น ดีบักได้เร็วขึ้น และตอบคำถามสัมภาษณ์งานระดับลึกได้ เพราะคุณจะเห็นว่าโค้ดหน้าตาเหมือนกันอาจสร้าง bytecode ที่ต่างกันมาก
หลายคนมองว่า Python เป็น "ภาษา interpreted" ซึ่งไม่ผิดทั้งหมด แต่ไม่ครบ ความจริงคือ CPython ซึ่งเป็น implementation มาตรฐานทำงานเป็น compiler ก่อน แล้วค่อยเป็น interpreter ต่อ ความเข้าใจนี้ใช้ได้จริงตอนปรับแต่งประสิทธิภาพ ตอนเลือกใช้ไลบรารี เช่นใน Python Libraries สำหรับ Data Science ที่นักพัฒนาควรรู้จักในปี 2026 ซึ่งหลายตัวเร็วเพราะย้ายงานหนักไปทำใน C แทนการวนลูปด้วย bytecode
Instagram serves 500 million daily users on Python despite the Global Interpreter Lock limiting thread parallelism
ข้อเท็จจริงข้างต้นบอกว่า "ช้ากว่า C++" ไม่ได้แปลว่า "ใช้งานจริงไม่ได้" เพราะคอขวดส่วนใหญ่ของระบบเว็บอยู่ที่ I/O และสถาปัตยกรรม ไม่ใช่ความเร็วของ bytecode
สิ่งที่ต้องเตรียม
- ติดตั้ง Python 3.11 ขึ้นไป (แนะนำเวอร์ชันล่าสุด) เช็คด้วย
python --version - ติดตั้ง VS Code พร้อม extension Python ของ Microsoft
- ความรู้พื้นฐาน Python เช่น ตัวแปร ฟังก์ชัน และ if/for
- เปิด Terminal ใน VS Code (กด
Ctrl+`) เพื่อรันสคริปต์
ตัวอย่างทั้งหมดใช้เฉพาะ standard library ไม่ต้องติดตั้งแพ็กเกจเพิ่ม ดูบทความหมวด Coding เพิ่มเติมได้ หากต้องการพื้นฐานภาษา
ขั้นตอนที่ Python เปลี่ยนโค้ดให้เป็นผลลัพธ์
CPython ประมวลผลโค้ดผ่านแปดขั้นตอนต่อเนื่องกัน ตั้งแต่การตัดข้อความเป็น token จนถึงการรัน bytecode แบบปรับตัวได้ แต่ละขั้นมีผลลัพธ์ของตัวเองที่คุณตรวจดูได้
ขั้นตอนที่ 1: Lexer แบ่งโค้ดเป็น token
Lexer (tokenizer) อ่านซอร์สโค้ดเป็นตัวอักษรแล้วตัดออกเป็นหน่วยย่อยที่เรียกว่า token เช่น NAME, NUMBER, OP และ NEWLINE โดยทำงานเหมือน deterministic finite automaton คือรู้สถานะปัจจุบันและตัวอักษรถัดไปแล้วตัดสินได้ทันทีว่าเป็น token ชนิดใด ลองดูด้วยโค้ดนี้
import tokenize
import io
source = "x = 10 + 5\n"
# generate_tokens รับฟังก์ชัน readline ที่คืนค่าทีละบรรทัด
for tok in tokenize.generate_tokens(io.StringIO(source).readline):
# tok.type เป็นตัวเลข ใช้ tokenize.tok_name แปลงเป็นชื่อ
print(tokenize.tok_name[tok.type], repr(tok.string))
ผลลัพธ์จะเห็น NAME 'x', OP '=', NUMBER '10' เรียงกันไป ตามด้วย NEWLINE
ขั้นตอนที่ 2: Parser สร้าง syntax tree ด้วย PEG
Parser นำ token มาจัดเป็นโครงสร้างตามไวยากรณ์ของภาษา Python 3.9 เปลี่ยนมาใช้ PEG parser แทน LL(1) เดิม ทำให้เขียนกฎไวยากรณ์ที่ซับซ้อนได้ง่ายขึ้น และเป็นพื้นฐานของไวยากรณ์ใหม่ เช่น match statement ในเชิงทฤษฎีผลลัพธ์ขั้นนี้คือ Concrete Syntax Tree ที่เก็บรายละเอียดของไวยากรณ์ครบถ้วน
ขั้นตอนที่ 3: สร้าง Abstract Syntax Tree (AST)
AST คือต้นไม้ที่ตัดรายละเอียดไวยากรณ์ที่ไม่จำเป็นออก เหลือเฉพาะความหมาย โดยมี node เช่น Module, FunctionDef, Assign ลองพิมพ์ดูเอง
import ast
tree = ast.parse("x = 10 + 5")
# indent=2 ช่วยให้อ่านโครงสร้างต้นไม้ง่ายขึ้น
print(ast.dump(tree, indent=2))
คุณจะเห็น Module ที่ครอบ Assign ซึ่งมี BinOp (การบวก) อยู่ข้างใน เครื่องมืออย่าง linter และ formatter ใช้ AST นี้ในการวิเคราะห์โค้ด
ขั้นตอนที่ 4: สร้าง Symbol Table
ก่อนคอมไพล์ Python ต้องรู้ว่าตัวแปรแต่ละตัวอยู่ใน scope ไหน ตัว symbol table builder จะเดินผ่าน AST แล้วจัดตัวแปรเป็น GLOBAL, LOCAL, FREE (ตัวแปรที่ฟังก์ชันภายในดึงมาจากภายนอก) และ CELL (ตัวแปรที่ถูกฟังก์ชันภายในอ้างถึง) ผลคือ Python เลือกคำสั่งโหลดตัวแปรได้ถูกชนิดตั้งแต่ตอนคอมไพล์ ไม่ต้องค้นหาชื่อซ้ำตอนรัน
import symtable
code = """
def outer():
a = 1
def inner():
return a
return inner
"""
table = symtable.symtable(code, "demo.py", "exec")
outer = table.get_children()[0]
for sym in outer.get_symbols():
# is_local และ is_referenced บอกชนิดของตัวแปร
print(sym.get_name(), "local:", sym.is_local(), "free:", sym.is_free())
ขั้นตอนที่ 5: Compiler สร้าง bytecode
Compiler แปลง AST เป็นคำสั่ง bytecode เก็บไว้ใน code object โดยค่า bytecode ดิบอยู่ในแอตทริบิวต์ co_code ที่เป็น bytes CPython compiles your code to bytecode with 163 opcodes before execution, not direct interpretation (จำนวน opcode จริงเปลี่ยนไปตามเวอร์ชัน)
code_obj = compile("x = 10 + 5", "demo", "exec")
print(code_obj.co_code) # bytes ดิบของ bytecode
print(code_obj.co_consts) # ค่าคงที่ที่ใช้ เช่น 15
สังเกตว่า co_consts มีค่า 15 อยู่แล้ว เพราะ compiler คำนวณ 10 + 5 ล่วงหน้า (constant folding) นี่คือตัวอย่างว่าทำไมการคอมไพล์ก่อนรันจึงมีประโยชน์
ขั้นตอนที่ 6: Virtual Machine โหลด bytecode เข้า evaluation loop
CPython VM เป็นสถาปัตยกรรมแบบ stack-based คือคำสั่งส่วนใหญ่ดันค่าเข้าและดึงค่าออกจากสแตก ในตำราคลาสสิกจะกล่าวถึง VALUE_STACK สำหรับเก็บค่าที่กำลังคำนวณ และ BLOCK_STACK สำหรับติดตามบล็อกอย่าง loop และ try (หมายเหตุ: ตั้งแต่ 3.11 การจัดการ exception ย้ายไปใช้ exception table แทน block stack แล้ว แต่แนวคิดสแตกค่ายังเหมือนเดิม)
ขั้นตอนที่ 7: Interpreter รัน opcode ทีละคำสั่ง
ใจกลางของ VM คือลูปขนาดใหญ่ที่อ่าน opcode แล้วกระโดดไปทำงานตามชนิดคำสั่ง (ในโค้ด C เป็น switch statement) เช่น LOAD_FAST โหลดตัวแปรโลคัล, BINARY_ADD บวกค่าสองตัวบนสุดของสแตก และ CALL_FUNCTION เรียกฟังก์ชัน ชื่อสองตัวหลังเป็นชื่อในเวอร์ชัน 3.10 และก่อนหน้า ส่วน 3.11 ขึ้นไปรวมเป็น BINARY_OP และ CALL ดูของจริงด้วย dis
import dis
def add(a, b):
return a + b
dis.dis(add) # แสดง bytecode แบบอ่านได้
ขั้นตอนที่ 8: Adaptive interpreter ปรับ opcode ให้เร็วขึ้น
Python 3.11 released October 2022 runs 10-60% faster than 3.10 using a specialized adaptive interpreter ตัว interpreter จะคอยเฝ้าดูโค้ดที่ถูกรันซ้ำ (hotspot) และหลังรันครบประมาณ 8 ครั้ง จะเปลี่ยน opcode ทั่วไปเป็นเวอร์ชันเฉพาะทาง เช่น บวกเลขจำนวนเต็มโดยตรงโดยไม่ต้องเช็คชนิดข้อมูลซ้ำ ลองดูผลได้ด้วยพารามิเตอร์ adaptive=True
import dis
def add(a, b):
return a + b
for _ in range(20): # รันซ้ำให้เกินเกณฑ์ warm-up
add(1, 2)
dis.dis(add, adaptive=True) # จะเห็น opcode แบบ specialized
ตัวอย่างโค้ดสมบูรณ์: ส่องทุกขั้นตอนในสคริปต์เดียว
สคริปต์นี้รวมทุกขั้นตอนไว้ในไฟล์เดียว บันทึกเป็น inspect_python.py ใน VS Code แล้วรันด้วย python inspect_python.py
import ast
import dis
import io
import tokenize
SOURCE = """
def total(prices):
result = 0
for p in prices:
result += p * 2
return result
"""
print("== 1) TOKENS ==")
for tok in tokenize.generate_tokens(io.StringIO(SOURCE).readline):
if tok.type not in (tokenize.NL, tokenize.NEWLINE, tokenize.INDENT, tokenize.DEDENT):
print(tokenize.tok_name[tok.type], repr(tok.string))
print("\n== 2) AST ==")
print(ast.dump(ast.parse(SOURCE), indent=2))
print("\n== 3) CODE OBJECT ==")
module_code = compile(SOURCE, "demo", "exec")
# หา code object ของฟังก์ชัน total ใน co_consts
func_code = next(c for c in module_code.co_consts if hasattr(c, "co_code"))
print("ชื่อตัวแปรโลคัล:", func_code.co_varnames)
print("ขนาด bytecode:", len(func_code.co_code), "bytes")
print("\n== 4) BYTECODE ==")
dis.dis(func_code)
print("\n== 5) รันจริง ==")
namespace = {}
exec(module_code, namespace) # รัน code object ที่คอมไพล์แล้ว
print(namespace["total"]([1, 2, 3])) # ผลลัพธ์ 12
ในผลลัพธ์ของส่วน bytecode คุณจะเห็น LOAD_FAST, FOR_ITER และ BINARY_OP ซึ่งตรงกับสิ่งที่อธิบายในขั้นตอนที่ 7
Garbage Collector ทำงานอย่างไรในภาพรวม?
Python จัดการหน่วยความจำด้วย reference counting เป็นหลัก แล้วเสริมด้วยตัวตรวจจับ cycle เพื่อเก็บ object ที่อ้างอิงกันเป็นวงกลม Python's garbage collector uses reference counting plus a cycle detector that runs every 700 object allocations by default (ค่านี้ปรับได้ด้วย gc.set_threshold() และอาจต่างกันในบางเวอร์ชัน)
เมื่อจำนวนอ้างอิงของ object เหลือ 0 หน่วยความจำจะถูกคืนทันที ส่วนกรณี a.ref = b และ b.ref = a ที่ชี้หากันเอง จำนวนอ้างอิงจะไม่ถึง 0 จึงต้องพึ่ง cycle detector ลองตรวจดูได้ด้วย import gc; print(gc.get_threshold()) ซึ่งปกติจะแสดงค่าแรกเป็น 700
เคล็ดลับและข้อควรระวัง
-
bytecode ไม่เท่ากันทุกเวอร์ชัน: ชื่อ opcode เปลี่ยนได้ระหว่างเวอร์ชัน อย่าเขียนโค้ดที่พึ่ง
co_codeโดยตรงใน production -
ใช้
disเทียบโค้ดสองแบบ: เช่น list comprehension กับ for loop เพื่อดูว่าแบบไหนสร้างคำสั่งน้อยกว่า แต่ให้วัดผลจริงด้วยtimeitเสมอ เพราะจำนวนคำสั่งไม่เท่ากับเวลา -
ไฟล์ .pyc: Python เก็บ bytecode ไว้ในโฟลเดอร์
__pycache__เพื่อข้ามขั้นคอมไพล์ในการ import ครั้งถัดไป สคริปต์ที่รันตรงๆ ไม่ถูกแคชแบบนี้ -
GIL: งานที่ใช้ CPU หนักควรใช้
multiprocessingหรือย้ายไปไลบรารีที่เขียนด้วย C - จดบันทึกการเรียนรู้: ถ้าคุณเรียนหรือประชุมทีมเรื่องเทคนิคบ่อยๆ ลองอ่าน Otter.ai เข้าประชุมของคุณโดยไม่ถามได้ยังไง เพื่อรู้เรื่องความเป็นส่วนตัวก่อนใช้เครื่องมือถอดเสียง
อีกเกร็ดที่น่าสนใจคือ Python's name comes from Monty Python's Flying Circus, not the snake, decided by Guido van Rossum in 1991 ซึ่งสะท้อนวัฒนธรรมสนุกๆ ของชุมชนนี้ และเป็นเหตุผลที่ตัวอย่างในเอกสารมักใช้คำว่า spam และ eggs
ขั้นตอนต่อไป
- ลองใช้
disกับฟังก์ชันที่คุณเขียนเองในโปรเจกต์ แล้วเปรียบเทียบเวอร์ชัน 3.10 กับ 3.11 ขึ้นไป - ศึกษาโมดูล
gcและsys.getrefcount()เพื่อเห็นการนับอ้างอิงจริง - เมื่อพร้อมแล้ว นำความรู้ไปสร้างบริการจริง เช่นแนวทางใน API สร้างรายได้ Passive บน RapidAPI ทำงานอย่างไร ซึ่งประสิทธิภาพของโค้ด Python ส่งผลต่อต้นทุนโดยตรง
- อ่านบทความแนวอธิบายแนวคิดเพิ่มเติมในหมวด Explainer
คำถามที่พบบ่อย (FAQ)
Python เป็นภาษา compiled หรือ interpreted?
A: เป็นทั้งสองอย่างผสมกัน CPython คอมไพล์ซอร์สโค้ดเป็น bytecode ก่อน แล้วให้ Virtual Machine ตีความ bytecode นั้นทีละคำสั่ง จึงไม่ใช่การแปลเป็นภาษาเครื่องโดยตรงแบบ C++
ไฟล์ .pyc คืออะไร และต้องลบไหม?
A: ไฟล์ .pyc คือ bytecode ที่ Python แคชไว้ในโฟลเดอร์ __pycache__ เพื่อให้ import เร็วขึ้น ไม่ต้องลบ และลบได้ปลอดภัยเพราะ Python จะสร้างใหม่ให้อัตโนมัติ ควรใส่ไว้ใน .gitignore
ทำไม Python 3.11 ถึงเร็วกว่า 3.10?
A: เพราะมี adaptive interpreter ที่ปรับ opcode ของโค้ดที่รันซ้ำให้เป็นเวอร์ชันเฉพาะทาง ร่วมกับการปรับปรุงการเรียกฟังก์ชันและการจัดการ frame ผลคือเร็วขึ้นราว 10-60% ขึ้นกับลักษณะงาน
GIL ทำให้ Python ใช้กับระบบใหญ่ไม่ได้จริงหรือ?
A: ไม่จริง GIL จำกัดเฉพาะการทำงานแบบ thread ขนานสำหรับงาน CPU หนัก แต่งานเว็บส่วนใหญ่ติด I/O และแก้ได้ด้วยหลาย process ตัวอย่างคือ Instagram ที่ให้บริการผู้ใช้ 500 ล้านคนต่อวันบน Python
But wait until you see what Python's hidden __slots__ does to memory usage
อัปเดตล่าสุด: 23 October 2026 บน AiDevThai
Originally published on AI Dev Thai. Daily AI tutorials, coding guides, and tech insights in Thai.
來源:DEV Community · dev.to
