Bài Python chạy đồng thời thế nào dừng ở tầng event loop và kernel. Bài này đi vào bên trong chính trình thông dịch CPython, quanh một câu hay được nhắc: trong Python, mọi thứ là object. Con số là object, hàm là object, class là object, và cả module, package cũng là object. Hiểu câu đó đến nơi đến chốn thì cơ chế import, vốn hay bị coi là phép màu, hóa ra rất đơn giản.
Mọi đoạn code trong bài đều chạy thật trên CPython 3.12.
Mọi thứ là object
import sys
import types
import json
def greet(name):
return f"Hello {name}"
class User:
pass
for thing in (42, "hi", None, greet, User, User(), int, type, json, sys):
print(f"{type(thing).__name__:<9} isinstance(..., object) = {isinstance(thing, object)}")
print("type(type) is type:", type(type) is type)
print("type(json) is types.ModuleType:", type(json) is types.ModuleType)
int isinstance(..., object) = True
str isinstance(..., object) = True
NoneType isinstance(..., object) = True
function isinstance(..., object) = True
type isinstance(..., object) = True
User isinstance(..., object) = True
type isinstance(..., object) = True
type isinstance(..., object) = True
module isinstance(..., object) = True
module isinstance(..., object) = True
type(type) is type: True
type(json) is types.ModuleType: True
Số 42, chuỗi, None, một hàm, một class, cả int và type, và module json, sys: tất cả đều là object. Đáng để ý nhất là hai dòng cuối. Kiểu của một class là type, kiểu của type là chính nó, còn module chỉ là một object kiểu ModuleType.
Một object trong bộ nhớ trông thế nào
Trong mã C của CPython, mọi object đều bắt đầu bằng cùng một phần đầu (PyObject), gồm hai trường:
ob_refcnt: có bao nhiêu chỗ đang trỏ tới object này.ob_type: con trỏ tới object kiểu của nó.
Phần còn lại tùy kiểu: một số nguyên giữ các chữ số, một list giữ mảng con trỏ tới các phần tử. Vì phần đầu giống nhau, mã C có thể xử lý mọi object như nhau: đọc ob_type để biết phải làm gì với nó. Trong CPython, id() chính là địa chỉ bộ nhớ của object, nên có thể đọc thẳng phần đầu đó bằng ctypes:
import ctypes
class PyObjectHeader(ctypes.Structure):
# The first two fields of every object in CPython (Include/object.h)
_fields_ = [("ob_refcnt", ctypes.c_ssize_t), ("ob_type", ctypes.c_void_p)]
def header(obj):
return PyObjectHeader.from_address(id(obj)) # in CPython, id() is the object's memory address
items = [1, 2, 3]
h = header(items)
print("refcount:", h.ob_refcnt)
alias = items
print("refcount after 'alias = items':", h.ob_refcnt)
del alias
print("refcount after 'del alias':", h.ob_refcnt)
print("ob_type points at list:", h.ob_type == id(list))
print("None's refcount:", header(None).ob_refcnt) # immortal since Python 3.12
refcount: 1
refcount after 'alias = items': 2
refcount after 'del alias': 1
ob_type points at list: True
None's refcount: 4294967295
Gán thêm một tên thì bộ đếm tăng, xóa tên thì giảm. Khi bộ đếm về 0, CPython giải phóng object ngay lập tức, không chờ một đợt dọn rác nào. Bộ đếm tham chiếu này chính là lý do có GIL mà bài trước nhắc tới: nếu hai thread cùng sửa ob_refcnt một lúc, con số sẽ sai.
Chỉ đếm tham chiếu thì không dọn được vòng tham chiếu, ví dụ hai object trỏ lẫn nhau. Vì vậy CPython có thêm bộ gom rác theo chu kỳ (module gc), chạy định kỳ để tìm những nhóm object chỉ còn trỏ lẫn nhau.
Con số 4294967295 của None không phải lỗi. Từ Python 3.12, những object dùng chung suốt đời chương trình như None, True hay các số nguyên nhỏ là immortal (bất tử): bộ đếm của chúng được đặt sẵn một giá trị đặc biệt và không bao giờ bị giải phóng.
Nói tới số nguyên nhỏ: CPython tạo sẵn các số từ -5 tới 256 lúc khởi động và dùng chung chúng.
a, b = int("256"), int("256")
c, d = int("257"), int("257")
print("256 is 256:", a is b) # -5..256 are created once at startup and shared
print("257 is 257:", c is d) # bigger ints are new objects each time
256 is 256: True
257 is 257: False
Đây là lý do không bao giờ dùng is để so sánh số: kết quả phụ thuộc vào chi tiết cài đặt. Dùng ==.
Class cũng là object, và thuộc tính được tra như thế nào
Một class là object kiểu type. Thuộc tính của nó nằm trong một dict, __dict__, và mỗi instance cũng có __dict__ riêng. Khi bạn viết an.role, Python tra lần lượt: instance trước, rồi tới từng class theo thứ tự MRO (method resolution order).
class User:
role = "member" # stored in the class's __dict__
def __init__(self, name):
self.name = name # stored in the instance's __dict__
def greet(self):
return f"Hi, I'm {self.name}"
class Admin(User):
role = "admin"
an = Admin("An")
print("instance dict:", an.__dict__)
print("class dict has:", [k for k in Admin.__dict__ if not k.startswith("__")])
print("MRO:", [c.__name__ for c in Admin.__mro__])
print("an.role:", an.role, "(found in Admin, before User)")
method = an.greet # a function found on the class becomes a bound method
print(type(User.__dict__["greet"]).__name__, "->", type(method).__name__, "| self is an:", method.__self__ is an)
print("the same thing by hand:", User.__dict__["greet"].__get__(an, Admin)())
instance dict: {'name': 'An'}
class dict has: ['role']
MRO: ['Admin', 'User', 'object']
an.role: admin (found in Admin, before User)
function -> method | self is an: True
the same thing by hand: Hi, I'm An
Dòng cuối là điều thú vị nhất. Trong __dict__ của class, greet chỉ là một hàm bình thường. Nó thành bound method, tức là một hàm đã gắn sẵn self, nhờ cơ chế descriptor: object hàm có phương thức __get__, và khi được tra qua một instance, Python gọi __get__ để gắn instance đó vào. property, classmethod, staticmethod đều là descriptor, chạy bằng đúng cơ chế này.
Hàm, code object và bytecode
Một hàm là object chứa một code object: bytecode đã biên dịch, các hằng số, tên các biến.
import dis
def greet(name):
return "Hello " + name
code = greet.__code__ # the function object holds a code object
print(type(greet).__name__, "->", type(code).__name__)
print("constants:", code.co_consts, " variables:", code.co_varnames)
print("raw bytecode:", code.co_code.hex(" "))
dis.dis(greet)
function -> code
constants: (None, 'Hello ') variables: ('name',)
raw bytecode: 97 00 64 01 7c 00 7a 00 00 00 53 00
4 0 RESUME 0
5 2 LOAD_CONST 1 ('Hello ')
4 LOAD_FAST 0 (name)
6 BINARY_OP 0 (+)
10 RETURN_VALUE
Khi bạn gọi hàm, CPython tạo một frame, một vùng chứa biến cục bộ và vị trí lệnh đang chạy, rồi vòng lặp thông dịch viết bằng C chạy từng lệnh bytecode: nạp hằng 'Hello ', nạp biến name, cộng, trả về. Hai byte 00 00 sau BINARY_OP là ô nhớ đệm: từ Python 3.11, trình thông dịch tự "chuyên biệt hóa" những lệnh chạy nhiều lần, ví dụ thay phép cộng chung bằng phép cộng chuỗi nếu lần nào cũng thấy hai chuỗi, và ô đệm này giữ thông tin cho việc đó.
Frame cũng là chìa khóa của coroutine trong bài trước. Một hàm thường xong là frame bị bỏ. Một coroutine thì giữ frame lại khi tạm dừng ở await, cùng với biến cục bộ và vị trí lệnh, nên send() có thể cho nó chạy tiếp đúng chỗ cũ.
Module là object
Nếu module chỉ là một object, thì import có thể làm bằng tay: tạo object module, chạy code trong __dict__ của nó, rồi cất vào một chỗ để lần sau lấy lại.
import sys
import types
# What `import hello` does, by hand: make a module object, run its code in the module's namespace, cache it
hello = types.ModuleType("hello")
exec("GREETING = 'xin chào'\ndef greet(name):\n return f'{GREETING}, {name}'", hello.__dict__)
sys.modules["hello"] = hello
import hello as again # noqa: E402 (found in sys.modules, so no file is looked for)
print(again is hello, again.greet("An"))
print(sorted(k for k in hello.__dict__ if not k.startswith("__")))
True xin chào, An
['GREETING', 'greet']
Biến toàn cục của một module thật ra chỉ là các khóa trong __dict__ của object module đó. Câu import hello thứ hai không tìm file nào cả, vì hello đã có trong sys.modules, cái dict mà Python dùng làm bộ đệm cho mọi module đã nạp.
import làm gì, từng bước
Câu import json thật ra là năm bước:
- Xem
sys.modules. Nếu"json"đã có thì lấy luôn, xong. Vì vậy code ở cấp module chỉ chạy một lần cho mỗi process, dù được import ở bao nhiêu chỗ. - Tìm. Hỏi lần lượt từng finder trong
sys.meta_path: "anh có biết module tênjsonkhông?". Finder nào biết thì trả về một spec (ModuleSpec), mô tả module ở đâu và loader nào nạp được nó. - Tạo object module từ spec, và cất ngay vào
sys.modules, trước khi chạy code của nó. - Chạy. Loader lấy code object (từ file
.pyc, hoặc biên dịch từ.py) rồi chạy nó trong__dict__của module. - Gán tên. Gắn tên
jsonvào namespace của nơi đã gọiimport. Cònfrom json import loadsthì làm đủ các bước trên rồi lấy thêm thuộc tínhloadsra.
Danh sách finder và spec của vài module:
import importlib.util
import sys
for finder in sys.meta_path:
print("meta_path:", getattr(finder, "__name__", finder))
for name in ("sys", "json", "json.decoder"):
spec = importlib.util.find_spec(name)
loader = getattr(spec.loader, "__name__", type(spec.loader).__name__) # BuiltinImporter is used as a class
print(f"{name:<13} loader={loader:<17} origin={spec.origin} package_path={spec.submodule_search_locations}")
meta_path: <_distutils_hack.DistutilsMetaFinder object at 0x7f1b72b02a80>
meta_path: BuiltinImporter
meta_path: FrozenImporter
meta_path: PathFinder
sys loader=BuiltinImporter origin=built-in package_path=None
json loader=SourceFileLoader origin=<python>/lib/python3.12/json/__init__.py package_path=['<python>/lib/python3.12/json']
json.decoder loader=SourceFileLoader origin=<python>/lib/python3.12/json/decoder.py package_path=None
Mặc định có ba finder: BuiltinImporter cho module biên dịch sẵn trong trình thông dịch như sys, FrozenImporter cho module "đông lạnh" vào file chạy, và PathFinder tìm trong các thư mục của sys.path. Dòng đầu là một ví dụ sống cho việc sys.meta_path mở rộng được: setuptools đã tự chèn thêm một finder của nó vào môi trường Python này.
Package là module có __path__
Một package chỉ là một module có thêm thuộc tính __path__: danh sách thư mục để tìm module con. Với một package thường, module chính là file __init__.py.
print("running shop/__init__.py")
VERSION = "1.0"
print("running shop/cart.py")
def total(prices):
return sum(prices)
import sys
import shop.cart
print("shop is a package:", hasattr(shop, "__path__"), shop.__path__)
print("cart became an attribute of shop:", shop.cart is sys.modules["shop.cart"])
print("total:", shop.cart.total([10, 20]))
import shop.cart # noqa: E402,F811 (already in sys.modules: nothing runs again)
print("cached modules:", [name for name in sys.modules if name.startswith("shop")])
running shop/__init__.py
running shop/cart.py
shop is a package: True ['.../demo/shop']
cart became an attribute of shop: True
total: 30
cached modules: ['shop', 'shop.cart']
import shop.cart chạy shop/__init__.py trước, vì phải có package cha trước đã, rồi mới tới shop/cart.py. Module con được gắn thành thuộc tính của package cha, và cả hai cùng nằm trong sys.modules. Lần import thứ hai không in gì nữa: không file nào được chạy lại.
Nếu thư mục không có __init__.py, Python tạo ra một namespace package: một package có thể trải ra nhiều thư mục khác nhau. Cách này hữu ích cho hệ thống plugin, nơi mỗi plugin được cài vào một thư mục riêng.
import sys
sys.path[:0] = ["plugins_a", "plugins_b"] # two folders, both with plugins/ and no __init__.py
import plugins.csv # noqa: E402
import plugins.pdf # noqa: E402
print("__file__:", plugins.__file__)
print("__path__:", list(plugins.__path__))
print(plugins.pdf.NAME, plugins.csv.NAME)
__file__: None
__path__: ['.../demo/plugins_a/plugins', '.../demo/plugins_b/plugins']
pdf csv
Namespace package không có __file__, vì không có file nào làm module chính, và __path__ của nó gồm cả hai thư mục.
__pycache__ và file .pyc
Biên dịch .py thành bytecode tốn thời gian, nên lần đầu import, CPython ghi code object đã biên dịch ra __pycache__/<tên>.cpython-312.pyc. Lần sau, nó kiểm tra file này còn khớp với nguồn không rồi dùng luôn.
import dis
import importlib.util
import marshal
import struct
from pathlib import Path
path = Path(importlib.util.cache_from_source("shop/cart.py"))
data = path.read_bytes()
magic, flags, mtime, size = data[:4], *struct.unpack("<3I", data[4:16]) # 16-byte header (PEP 552)
print("file:", path)
print("magic matches this Python:", magic == importlib.util.MAGIC_NUMBER, magic.hex(" "))
print("flags:", flags, " source mtime:", mtime, " source size:", size, "bytes")
print("source mtime on disk:", int(Path("shop/cart.py").stat().st_mtime))
code = marshal.loads(data[16:]) # the rest is the module's code object, serialized
print(type(code).__name__, code.co_names)
file: shop/__pycache__/cart.cpython-312.pyc
magic matches this Python: True cb 0d 0d 0a
flags: 0 source mtime: 1792140599 source size: 74 bytes
source mtime on disk: 1792140599
code ('print', 'total')
Phần đầu file .pyc dài 16 byte:
- Magic number: đổi theo phiên bản bytecode, nên Python 3.13 không dùng nhầm
.pyccủa 3.12. Tên file cũng cócpython-312vì cùng lý do. - Flags:
0nghĩa là kiểm tra theo thời gian sửa file. Còn một chế độ khác kiểm tra theo hash của nội dung, dùng khi build cần cho ra kết quả giống hệt nhau mỗi lần. - Thời gian sửa và kích thước của file nguồn: nếu khác với file
.pyhiện tại thì.pycbị coi là cũ và được biên dịch lại.
Phần còn lại là code object, được tuần tự hóa bằng module marshal.
import chạm vào ổ đĩa thế nào
Theo dõi các system call của import json.decoder bằng strace, như cách làm ở bài trước (đã lược bớt):
stat(".../json/__init__.cpython-312-x86_64-linux-gnu.so") = -1 ENOENT
stat(".../json/__init__.abi3.so") = -1 ENOENT
stat(".../json/__init__.so") = -1 ENOENT
stat(".../json/__init__.py") = 0
openat(".../json/__pycache__/__init__.cpython-312.pyc") = 3
openat(".../json", O_DIRECTORY) = 3
stat(".../json/decoder.py") = 0
openat(".../json/__pycache__/decoder.cpython-312.pyc") = 3
Với mỗi module, PathFinder thử lần lượt các dạng có thể có: thư viện C đã biên dịch (.so) trước, rồi tới .py. Thấy .py thì nó mở file .pyc tương ứng trong __pycache__. Dòng openat(".../json", O_DIRECTORY) là lúc nó đọc danh sách file của thư mục package một lần rồi giữ lại, để các module con sau đó không phải hỏi ổ đĩa nhiều lần.
Muốn biết import nào chậm, Python có sẵn -X importtime:
import time: self [us] | cumulative | imported package
import time: 370 | 4812 | re
import time: 35 | 35 | _json
import time: 284 | 318 | json.scanner
import time: 336 | 5465 | json.decoder
import time: 276 | 276 | json.encoder
import time: 354 | 6094 | json
Cột giữa là tổng thời gian tính bằng micro giây, kể cả các module con. Import json mất khoảng 6 ms, phần lớn là để nạp re. Với một app lớn, import có thể chiếm phần đáng kể thời gian khởi động, và lệnh này cho biết chính xác phần nào.
Import vòng tròn
Bước 3 ở trên, cất module vào sys.modules trước khi chạy code của nó, là thứ cho phép import vòng tròn không lặp vô hạn. Nhưng nó cũng sinh ra một lỗi quen thuộc:
import b
LIMIT = 10
def check(n):
return n <= b.limit()
from a import LIMIT # a is only half done when this runs
def limit():
return LIMIT
ImportError: cannot import name 'LIMIT' from partially initialized module 'a' (most likely due to a circular import)
Khi chạy import a: a được tạo và cất vào sys.modules, rồi dòng đầu của a import b. b lại cần LIMIT từ a, nhưng lúc đó a mới chạy được đúng một dòng, nên LIMIT chưa tồn tại. Thông báo lỗi nói rất đúng: partially initialized module, module mới khởi tạo một nửa.
Cách sửa: lấy cả object module, rồi chỉ đọc thuộc tính khi thật sự cần.
import a # take the module object, not a name from inside it
def limit():
return a.LIMIT # looked up when called, by which time a has finished running
Khi limit() được gọi thì a đã chạy xong từ lâu. Dù vậy, cách sửa tốt hơn thường là tách phần dùng chung ra một module thứ ba để không còn vòng nữa.
Tự viết một finder
Vì import đi qua sys.meta_path, bạn có thể chèn finder của riêng mình vào đó. Đây là một finder nạp module từ một dict, không cần file nào:
import importlib.abc
import importlib.util
import sys
SOURCES = {"greetings": "def hello(name):\n return f'Xin chào {name}'\n"} # code that lives in no file
class DictFinder(importlib.abc.MetaPathFinder, importlib.abc.Loader):
def find_spec(self, name, path, target=None):
if name in SOURCES:
return importlib.util.spec_from_loader(name, self)
return None # not ours: let the next finder try
def exec_module(self, module):
exec(SOURCES[module.__name__], module.__dict__)
sys.meta_path.insert(0, DictFinder())
import greetings # noqa: E402
print(greetings.hello("An"), "| loaded by", type(greetings.__spec__.loader).__name__)
Xin chào An | loaded by DictFinder
pytest dùng đúng cơ chế này để viết lại các câu assert trong file test lúc import, nhờ vậy khi test fail nó in ra được giá trị của từng vế.
Quay lại FastAPI
Lệnh uvicorn src.main:app dùng đúng những thứ ở trên. Trong mã nguồn uvicorn, hàm import_from_string tách chuỗi ở dấu :, gọi importlib.import_module("src.main"), rồi lấy thuộc tính app bằng getattr. Từ đó:
- Import
src.mainchạy toàn bộ code cấp module của nó: tạoapp = FastAPI(...), gọiinclude_routercho từng router, và để làm vậy thì import mọi module router, rồi tới mọi thứ các module đó import. - Code cấp module chỉ chạy một lần mỗi process. Website này dựa vào điều đó:
src/blog/service.pycó dòngPOSTS = load_posts(...), nên mọi bài viết, kể cả bài bạn đang đọc, được đọc từ ổ đĩa và chuyển thành HTML đúng một lần lúc khởi động, rồi nằm sẵn trong bộ nhớ. - Mỗi worker uvicorn là một process riêng, nên mỗi worker có
sys.modulesriêng và tự import lại từ đầu. Đây là lý do khác, ở tầng import, khiến trạng thái không thể chia sẻ giữa các worker như bài trước đã nói. --reloadkhởi động lại cả process khi file thay đổi, thay vì import lại.sys.modulesđã giữ module cũ, và mọi object được tạo từ code cũ vẫn trỏ tới hàm và class cũ; làm mới từng module một cách an toàn gần như không thể.
Tóm lại
| Khái niệm | Thực chất |
|---|---|
| Object | Một vùng nhớ bắt đầu bằng bộ đếm tham chiếu và con trỏ tới kiểu |
| Class | Một object kiểu type, thuộc tính nằm trong __dict__ |
| Method | Một hàm, được gắn self nhờ descriptor __get__ |
| Hàm | Một object chứa code object (bytecode); mỗi lần gọi tạo một frame |
| Module | Một object ModuleType; biến toàn cục là khóa trong __dict__ của nó |
| Package | Một module có __path__ |
import |
Xem sys.modules → hỏi finder → tạo module → chạy code → gán tên |
.pyc |
Code object đã tuần tự hóa, kèm magic number và dấu vết của file nguồn |
Hiểu "mọi thứ là object" thì nhiều câu hỏi tự có lời giải: vì sao code cấp module chỉ chạy một lần, vì sao import vòng tròn báo "partially initialized", vì sao is không dùng để so sánh số, và vì sao uvicorn cần một chuỗi dạng module:thuộc_tính để tìm ra app của bạn.