← Lập trình Python cơ bản

Bài 8 · Nâng cao · 26 phút· Cập nhật 11/06/2026

Bản chất: bytecode & máy ảo CPython

Biên soạn bởi Nguyễn Anh Tuấn

Python chạy thế nào ở bản chất: xem bytecode bằng module dis, stack machine, tệp .pyc trong __pycache__ và vòng lặp eval của máy ảo CPython.

bài Làm quen Python ta biết Python biên dịch sang bytecode rồi máy ảo chạy. Giờ ta nhìn tận mắt bytecode đó bằng module chuẩn dis:

xem bytecode thật

import dis
dis.dis("kq = a + b * c")

Kết quả khi chạy

  0           RESUME                   0

  1           LOAD_NAME                0 (a)
              LOAD_NAME                1 (b)
              LOAD_NAME                2 (c)
              BINARY_OP                5 (*)
              BINARY_OP                0 (+)
              STORE_NAME               3 (kq)
              LOAD_CONST               0 (None)
              RETURN_VALUE
  • dis.dis(...) in ra bytecode CPython sinh ra - thấy đúng những gì Python thực sự làm, khỏi đoán.
  • RESUME ở đầu và LOAD_CONST None + RETURN_VALUE ở cuối là khung mặc định của mỗi module.
  • Sáu lệnh giữa chính là biểu thức kq = a + b * c của ta.

Máy ảo CPython là một stack machine (máy ảo dùng stack - ngăn xếp): LOAD_* đẩy một giá trị lên đỉnh; BINARY_OP / COMPARE_OP lấy vài giá trị xuống, tính, rồi đẩy kết quả; STORE_NAME lấy đỉnh ra cất vào biến. Bấm Tiếp → để xem stack đổi theo từng lệnh:

kq = a + b * c · a=2b=3c=4
bytecode
Stack giá trị
2 ← đỉnh
lệnh 1/6
Đẩy giá trị của a (= 2) lên đỉnh stack.
  • LOAD_NAME a: đẩy giá trị của a lên đỉnh stack.
  • BINARY_OP (+): lấy 2 số trên đỉnh, cộng, đẩy tổng lại.
  • STORE_NAME kq: lấy đỉnh ra, lưu vào biến kq.

Vì sao a + b * c tính nhân trước? Vì trình biên dịch sắp lệnh theo ưu tiên: nó đặt BINARY_OP * trước BINARY_OP +. Đổi sang (a + b) * c thì thứ tự hai lệnh đảo lại - và kết quả đổi từ 14 thành 20 (chọn hai tab đầu ở công cụ trên để so sánh):

  • Thứ tự các BINARY_OP trên stack CHÍNH LÀ thứ tự tính toán.
  • a + b * c → nhân (*) trước → 14; (a + b) * c → cộng (+) trước → 20.
  • Dấu ngoặc không còn tồn tại lúc chạy - nó đã đổi BYTECODE ngay từ lúc biên dịch.

Ai “chạy” chuỗi bytecode? Một vòng lặp eval viết bằng C (trong ceval.c của CPython): nó đọc từng bytecode và thực thi trên stack, lặp tới hết. Bytecode được lưu sẵn trong .pyc để khỏi dịch lại:

bức tranh tổng thể

hello.py  (mã nguồn)
   │  biên dịch (compile)
   ▼
bytecode  ──lưu──▶  __pycache__/hello.cpython-314.pyc
   │  vòng lặp eval (ceval.c, viết bằng C)
   ▼  đọc & chạy từng lệnh trên STACK
kết quả

Nối lại với bài Làm quen Python

Đây chính là lớp “máy ảo CPython” trong sơ đồ bài Làm quen Python. Bytecode độc lập CPU nên cùng một .py chạy được mọi nơi có CPython; cái giá là thêm một lớp thông dịch so với mã máy của C.

Soi bytecode cho thấy CPython làm sẵn nhiều thứ. Ba ví dụ (chạy thử dis trên máy bạn):

gấp hằng số (constant folding)

import dis
dis.dis("x = 2 + 3")

Kết quả khi chạy

  LOAD_SMALL_INT     5        <- da tinh san 2 + 3 = 5 luc BIEN DICH!
  STORE_NAME         0 (x)
  LOAD_CONST         1 (None)
  RETURN_VALUE

biến cục bộ trong hàm: họ LOAD_FAST (nhanh hơn)

def cong(a, b):
    return a + b
dis.dis(cong)

Kết quả khi chạy

  RESUME
  LOAD_FAST_BORROW_LOAD_FAST_BORROW   (a, b)   <- bien CUC BO: tra theo VI TRI
  BINARY_OP                           (+)
  RETURN_VALUE
  • Gấp hằng số: 2 + 3 được tính SẴN lúc biên dịch → không có BINARY_OP, chỉ nạp 5.
  • Biến cục bộ trong hàm dùng họ LOAD_FAST (tra theo VỊ TRÍ) - nhanh hơn LOAD_NAME (tra theo TÊN ở module).
  • So sánh (a > b) sinh COMPARE_OP: lấy 2 giá trị, đẩy True/False lên stack.

Vì sao nên giữ code “bình thường”

Những tối ưu này chạy tự động - code càng rõ ràng, kiểu dữ liệu càng ổn định thì càng được lợi. Bạn không cần “viết mẹo” - cứ viết sạch, CPython lo phần còn lại. (Khoá nâng cao cho thấy 3.11+ còn chuyên biệt hoá các lệnh nóng để nhanh hơn nữa.)
  • Gỡ lỗi khó: thấy Python THẬT SỰ làm gì, không phải đoán.
  • Hiểu hiệu năng: vì sao một cách viết sinh ít/nhiều lệnh hơn.
  • Hiểu các tối ưu của CPython - nền cho khoá nâng cao.

Sắp tới (khoá nâng cao)

Mọi thứ chạy nhanh dần của CPython đều thao tác trên chính bytecode này: trình thông dịch thích ứng (3.11) chuyên biệt hoá các lệnh hay chạy, JIT (3.13) dịch điểm nóng sang mã máy, và GIL / free-threading quyết định nhiều luồng cùng chạy bytecode ra sao. Hẹn gặp ở Python nâng cao.

Câu hỏi thường gặp

dis (disassembler) là module chuẩn cho xem BYTECODE mà CPython sinh ra từ code của bạn. import dis; dis.dis(ham_hoac_chuoi) in ra danh sách lệnh bytecode. Đây là cách “nhìn vào bản chất” chính thức, không cần công cụ ngoài.

Máy ảo CPython không dùng “thanh ghi” như CPU; nó dùng một STACK giá trị. Hầu hết lệnh bytecode chỉ ĐẨY (push) giá trị lên đỉnh hoặc LẤY (pop) vài giá trị từ đỉnh, tính, rồi đẩy kết quả. Vd a + b: đẩy a, đẩy b, rồi BINARY_OP lấy 2 cái xuống, cộng, đẩy tổng lên.

Vì trình biên dịch sắp bytecode theo ưu tiên: nó nhân b*c TRƯỚC (BINARY_OP *) rồi mới cộng a (BINARY_OP +). Thứ tự các lệnh trên stack CHÍNH LÀ ưu tiên toán tử. Thêm ngoặc (a + b) * c đổi thứ tự lệnh → kết quả khác. Xem công cụ ở Bước 2-3.

Là trái tim của CPython: một vòng lặp lớn (viết bằng C, trong tệp ceval.c) đọc TỪNG bytecode và thực thi nó trên stack, lặp cho tới hết. “Máy ảo CPython” chính là vòng lặp đó.

Chứa chính bytecode đã biên dịch (cùng ít metadata), lưu trong __pycache__ để lần sau khỏi dịch lại (nhắc ở bài Làm quen Python). Nó KHÔNG phải mã máy; vẫn cần máy ảo chạy. Xoá được, Python tạo lại.

Hiểu Python “thật sự” làm gì giúp: gỡ những lỗi khó, đoán vì sao một cách viết nhanh/chậm hơn, và hiểu các tối ưu của CPython. Ở khoá nâng cao, GIL, trình thông dịch thích ứng (3.11) và JIT (3.13) đều thao tác trên chính bytecode này.

Tick những điều em tự tin làm được. Càng lên cao, em càng hiểu sâu.

Tick những điều em tự tin làm được sau khi học bài này. 0/6

Trả lời vài câu để chắc rằng em đã nắm bài.

Câu 1/3 Điểm: 0

Module dis trong Python dùng để làm gì?

  1. 1

    Tự xem bytecode

    Chạy: import dis; dis.dis("kq = a + b * c"). Có bao nhiêu lệnh BINARY_OP và theo thứ tự nào?

    Hoàn thành khi: Hai BINARY_OP: * trước, + sau - đúng ưu tiên nhân trước cộng.

  2. 2

    Bước qua stack

    Dùng công cụ Bước 2, chọn “a + b * c”, bấm Tiếp từng lệnh. Stack cao nhất mấy phần tử?

    Hoàn thành khi: 3 phần tử (a, b, c) trước khi BINARY_OP * lấy bớt xuống.

  3. 3

    Ngoặc đổi bytecode

    So sánh bytecode của “a + b * c” và “(a + b) * c” trên công cụ. Khác nhau chỗ nào?

    Hoàn thành khi: Thứ tự lệnh khác: bản ngoặc cộng (+) TRƯỚC rồi mới nhân (*); kết quả 20 thay vì 14.

  4. 4

    Hằng số bị gấp

    Chạy dis.dis("x = 2 + 3"). Vì sao không thấy BINARY_OP nào?

    Hoàn thành khi: Trình biên dịch “gấp hằng số” (constant folding): tính sẵn 5 lúc biên dịch → chỉ còn nạp 5 và lưu.

  5. 5

    So sánh

    Trên công cụ chọn “a > b”. Lệnh nào làm việc so sánh, kết quả đẩy lên stack là gì?

    Hoàn thành khi: COMPARE_OP (>) lấy 2 số, đẩy True/False; với a=2, b=3 ra False.

  6. 6

    Bytecode của hàm

    Viết def cong(a, b): return a + b rồi dis.dis(cong). Bytecode có giống biểu thức rời không?

    Hoàn thành khi: Tương tự (nạp a, nạp b, BINARY_OP +, trả về) - nhưng hàm dùng LOAD_FAST cho biến cục bộ (nhanh hơn LOAD_NAME).