PEP 810 – Import trì hoãn tường minh
(pep-previews--4622.org.readthedocs.build)- Trong Python, thông lệ phổ biến là khai báo toàn bộ import ở cấp độ module
- Tuy nhiên, khi chương trình chạy, ngay cả các module phụ thuộc không cần thiết cũng bị nạp ngay lập tức, gây ra vấn đề về tốc độ khởi động và mức sử dụng bộ nhớ
- Trước đây, người ta thường dùng import trì hoãn thủ công như đặt import bên trong hàm, nhưng cách này có nhược điểm là khó bảo trì và quản lý phụ thuộc
- PEP 810 lần này giới thiệu cú pháp import trì hoãn tường minh với từ khóa
lazymới theo hướng local, explicit, controlled, granular - Nhờ tính năng này, module chỉ được nạp đúng vào thời điểm thực sự cần, đồng thời cải thiện độ trễ khởi động, lãng phí bộ nhớ và độ minh bạch của cấu trúc mã
Hiện trạng và vấn đề của import trong Python
- Trong Python, thông lệ viết câu lệnh import ở đầu module được sử dụng rất rộng rãi
- Cách này giúp giảm trùng lặp, cho phép nhìn thấy cấu trúc phụ thuộc import một cách trực quan, và chỉ import một lần để giảm thiểu overhead lúc runtime
- Tuy nhiên, khi chương trình chạy và module đầu tiên (main) được nạp, rất dễ xảy ra chuỗi import liên hoàn khiến nhiều module phụ thuộc không thực sự được dùng cũng bị đọc ngay lập tức
- Đặc biệt với công cụ CLI, chỉ cần gọi phần help tổng thể cũng có thể khiến hàng chục module bị nạp trước, tạo ra overhead không cần thiết cho mọi subcommand
Các giải pháp thay thế trước đây và vấn đề của chúng
- Cách trì hoãn thời điểm import bằng thủ công, chẳng hạn chuyển import vào bên trong hàm, được dùng khá thường xuyên
- Tuy nhiên, cách này có nhược điểm lớn như làm giảm tính nhất quán, khả năng bảo trì, và tăng độ khó khi nắm bắt toàn bộ phụ thuộc
- Kết quả phân tích thư viện chuẩn cho thấy trong mã nhạy cảm về hiệu năng, khoảng 17% tổng số import đã được dùng bên trong hàm hoặc method với mục đích trì hoãn import
- Các công cụ liên quan đến trì hoãn import như
importlib.util.LazyLoaderhay gói bên thứ balazy_loadervẫn chưa đáp ứng được mọi trường hợp hoặc thiếu một chuẩn thống nhất
PEP 810: đưa import trì hoãn tường minh vào Python
-
Giới thiệu soft keyword
lazymới (chỉ mang ý nghĩa trong ngữ cảnh nhất định, vẫn có thể dùng làm tên biến, v.v.) -
lazychỉ được dùng trước câu lệnh import, không thể dùng trong phạm vi hàm/lớp/with/try hoặc với star import -
Áp dụng rõ ràng theo từng câu lệnh import để trì hoãn việc nạp module cho tới thời điểm sử dụng
lazy import 모듈명 lazy from 모듈명 import 이름
Cách triển khai import trì hoãn tường minh và quy tắc cú pháp
-
Các trường hợp lỗi cú pháp:
- Không cho phép bên trong hàm, bên trong lớp,
try/with, và star import (*)
- Không cho phép bên trong hàm, bên trong lớp,
-
Ví dụ sử dụng:
import sys lazy import json print('json' in sys.modules) # False (chưa được nạp) result = json.dumps({"hello": "world"}) # nạp ở lần dùng đầu tiên print('json' in sys.modules) # True (đã hoàn tất nạp module trì hoãn) -
Có thể chỉ định danh sách chuỗi các mục tiêu lazy trong thuộc tính
__lazy_modules__ở cấp module__lazy_modules__ = ["json"] import json # được xử lý như lazy
Kiểm soát hành vi bằng cờ toàn cục và bộ lọc
-
Có thể dùng cờ toàn cục hoặc hàm lọc để kiểm soát việc áp dụng lazy ở cấp module hoặc toàn cục
-
Có thể dùng hàm lọc để áp dụng ngoại lệ eager import chỉ cho một số module nhất định
def my_filter(importer, name, fromlist): if name in {'problematic_module'}: return False # eager import return True # lazy import sys.set_lazy_imports_filter(my_filter)
Hành vi runtime và xử lý lỗi
-
Khi dùng lazy import, import thực sự không xảy ra tại câu lệnh import mà tại thời điểm truy cập tên lần đầu
-
Nếu import thất bại, chuỗi ngoại lệ (traceback chaining) sẽ hiển thị rõ cả vị trí định nghĩa lẫn vị trí phát sinh
lazy from json import dumsp # lỗi gõ sai result = dumsp({"key": "value"}) # ImportError phát sinh tại thời điểm truy cập thực tế
Lợi ích về bộ nhớ và hiệu năng
- Module bị trì hoãn chỉ xuất hiện trong tập sys.lazy_modules và chưa được đăng ký vào sys.modules trước khi thực sự được dùng
- Sau khi được dùng, nó được thay bằng đối tượng module bình thường và có thể sử dụng mà không chịu thêm penalty hiệu năng
- Trong môi trường workload thực tế, hiệu quả đạt được là giảm 50–70% độ trễ khởi động, tiết kiệm 30–40% bộ nhớ
Tóm tắt cơ chế hoạt động
- Ở lần truy cập đầu tiên vào lazy object sẽ diễn ra reification (import thực sự và thay thế)
- Khi mã bên ngoài truy cập
__dict__của module, mọi lazy object sẽ bị buộc nạp (reification) - Khi lấy dictionary bằng
globals(), lazy proxy vẫn được giữ nguyên và cần truy cập trực tiếp
Tối ưu cho type annotation và TYPE_CHECKING
- Với
lazy from 모듈 import 이름, các import chỉ dùng cho type có thể được đảm bảo chi phí runtime bằng ZERO - Cách này giúp thay thế điều kiện
from typing import TYPE_CHECKINGtrước đây, khiến mã ngắn gọn và rõ ràng hơn
Khác biệt với PEP 690 và đặc điểm triển khai
- PEP 810 là cấu trúc opt-in tường minh, theo từng import riêng lẻ, dựa trên proxy object đơn giản
- Trong khi đó, PEP 690 dùng cấu trúc lazy import toàn cục và ngầm định
Lưu ý và tương tác giữa các module
- Star import (
*) không được hỗ trợ theo kiểu lazy (luôn là eager) - Custom import hook và loader vẫn hoạt động nguyên vẹn tại thời điểm reification
- Ngay cả trong môi trường đa luồng, hệ thống vẫn bảo đảm chỉ import một lần theo cách thread-safe và bind an toàn
- Khi cùng một module được dùng cả lazy lẫn eager, phía eager luôn được ưu tiên
Hướng dẫn áp dụng và migration mã
- Khi áp dụng vào mã hiện có, nên profiling trước rồi chỉ chuyển những import cần thiết sang lazy, khuyến nghị triển khai dần dần
- Khi dùng
__lazy_modules__, vẫn có thể tương thích với các phiên bản trước Python 3.15
Các điểm hỏi đáp quan trọng khác
- Tác dụng phụ ở thời điểm import (ví dụ: mẫu đăng ký) sẽ bị trì hoãn đến lần truy cập đầu tiên. Nếu side effect là bắt buộc, nên dùng mẫu hàm khởi tạo tường minh
- Vấn đề circular import (import vòng) không thể được giải quyết hoàn toàn bằng lazy import (chỉ có thể giảm nhẹ nếu việc truy cập được trì hoãn)
- Hiệu năng đường nóng sau lần dùng đầu tiên sẽ tự động được tối ưu sao cho kiểm tra lazy biến mất hoàn toàn (bytecode adaptive specialization)
- Trong
sys.modules, module thực chỉ được đăng ký sau khi reification diễn ra (lần dùng đầu tiên) - Khác với
importlib.util.LazyLoader, không cần cấu hình riêng, vẫn giữ hiệu năng và có cú pháp chuẩn rõ ràng
Kết luận
- PEP 810 bổ sung từ khóa
lazycho câu lệnh import của Python, giúp tối ưu một cách ngắn gọn và có thể dự đoán được các vấn đề hiệu năng do nạp module không cần thiết trong nhiều bối cảnh như CLI nhiều subcommand, ứng dụng lớn, type annotation, v.v. - Từ khóa mới cho phép chỉ định rất chi tiết thời điểm và đối tượng áp dụng, nên phù hợp để triển khai dần và tinh chỉnh hiệu năng trong môi trường production
- Đây là một bước tiến thực chất của hệ thống import trong Python, đồng thời đáp ứng cả ba yêu cầu: khả năng quan sát, khả năng bảo trì và hiệu năng
1 bình luận
Ý kiến Hacker News
Công cụ CLI llm.datasette.io của tôi hỗ trợ plugin, nhưng có rất nhiều phàn nàn rằng ngay cả những lệnh như
llm --helpcũng khởi động quá chậm; kiểm tra ra thì các plugin phổ biến mặc định import những gói nặng như pytorch nên chặn toàn bộ quá trình khởi động, vì vậy tôi đã hướng dẫn trong tài liệu dành cho tác giả plugin rằng chỉ nên import dependency bên trong hàm khi thực sự cần (liên kết tài liệu liên quan); nhưng sẽ tốt hơn rất nhiều nếu vấn đề này được hỗ trợ ở cấp độ ngôn ngữ PythonCó thể triển khai tính năng này ngay hôm nay trong công cụ (liên kết giải thích), chỉ là cách này áp dụng global cho toàn bộ tiến trình nên nếu import numpy theo kiểu lazy thì mọi import của các submodule cũng đều bị lazy theo; kết quả là nếu không cần toàn bộ numpy thì nó có thể không bị import luôn, nhưng hiện tượng import từng phần của module vào những thời điểm cần thiết có thể bị phân tán khó dự đoán trong suốt runtime; thử nghiệm thêm cho thấy nếu import kiểu
import foo.bar.bazthìfoovàfoo.barvẫn được load ngay, còn chỉfoo.bar.bazlà bị trì hoãn, có lẽ đó cũng là một phần lý do PEP dùng từ "mostly"; nếu tôi cải tiến bản triển khai của mình thêm nữa thì có lẽ xử lý được chuyện nàyKhuyến nghị parse command line trước để xử lý các tùy chọn như
--helpmà không cần import, chỉ import khi thật sự cần, hoặc nói đơn giản là thiết kế sao cho chỉ import khi các tùy chọn lệnh đơn giản đã được xử lý xong mà vẫn còn việc phải làmĐề xuất lazy import đã từng có trước đây, gần nhất là bị bác vào năm 2022 (liên kết thảo luận liên quan); theo tôi nhớ thì lazy import đã có trong Cinder, biến thể CPython của Meta, và PEP lần này cũng do những người từng làm Cinder dẫn dắt; trọng tâm tranh luận là "opt-in hay opt-out?" "phạm vi áp dụng đến đâu?" "có nên đưa vào dưới dạng build flag của CPython không?" v.v.; cuối cùng Steering Council nói họ bác bỏ vì độ phức tạp khi hành vi import bị tách làm hai; hy vọng đề xuất lần này nhất định sẽ được thông qua, tôi thật sự muốn dùng tính năng này
Tôi đặc biệt thích việc nó là opt-in, lại còn có áp dụng theo mức độ chi tiết và cả công tắc tắt global; đây là một đặc tả được thiết kế rất tốt trong nhiều ràng buộc
Tôi cũng mong đề xuất này được thông qua, nhưng không lạc quan lắm; nó sẽ làm hỏng vô số đoạn mã và kéo theo hàng loạt vấn đề bất ngờ; bản thân câu lệnh import vốn có side effect, nên nếu thay đổi thời điểm áp dụng thì ta sẽ phải vật lộn rất lâu với những bug không rõ nguyên nhân; đây không phải hù dọa mà là lo ngại có cơ sở thật; có lý do vì sao lazy import chỉ từng có ở Meta—vì đây là chuyện dường như chỉ nơi nhiều tài nguyên như Meta mới gánh nổi; nhiều người chỉ nhìn vào chuyện "pandas, numpy, hoặc weird module rối rắm của tôi quá chậm nên nhanh hơn thì tốt" nhưng tôi nghĩ hiếm ai thực sự hiểu hệ thống import của Python vận hành ra sao; thậm chí có nhiều ý kiến ủng hộ mà còn không biết lazy import được triển khai thế nào; nhìn vào PEP 690 thì có khá nhiều nhược điểm—ví dụ, mã dùng decorator để thêm các hàm vào central registry sẽ bị hỏng; điển hình là thư viện Dash nối giao diện JavaScript với callback Python bằng decorator tại thời điểm import, nên nếu import trở thành lazy thì kiểu frontend này có thể chết hẳn; những dịch vụ có vô số người dùng cũng có thể hỏng ngay lập tức; người ta nói "vì là opt-in nên nếu không hợp thì tắt lazy import đi" nhưng nếu import là transitive thì sao? Nếu có trường hợp phải khởi động một tiến trình quan trọng sau khi frontend đã được khởi tạo hoàn chỉnh thì sao? Trong một hệ sinh thái nơi code và thư viện của rất nhiều người đan xen, ai mà biết được ảnh hưởng sẽ ra sao? Khác với type hint, đây là thay đổi tác động thực chất đến runtime; câu lệnh import có mặt trong mọi đoạn code Python đáng kể, nên nếu đưa lazy vào thì cách thực thi về căn bản sẽ thay đổi; ngoài ra còn nhiều trường hợp kỳ quặc khác mà PEP có nhắc tới; đây là bài toán khó hơn nhiều so với vẻ ngoài
Sẽ thật tuyệt nếu có thể import kèm chỉ định phiên bản như
import torch==2.6.0+cu124,import numpy>=1.2.6, và có thể cài đặt/import đồng thời nhiều phiên bản package trong cùng một môi trường Python; mong sao chấm dứt được địa ngục conda/virtualenv/docker/bazelTôi không ghét lắm nhưng cũng không quá hoan nghênh; cứ như thế này thì có vẻ gần như mọi
importsẽ phải gắn thêmlazy, trừ vài trường hợp thật sự cần eager import, còn lại thì gắn lazy hết nên code bị bừa bộn; mà vì cũng không có kế hoạch biến đây thành hành vi mặc định nên sự rườm rà đó sẽ tồn tại mãi mãi; tôi thà thích một hệ thống nơi phía module tự khai báo opt-in cho lazy loading hơn, còn cú pháp import thì không đổi; như vậy chỉ các thư viện lớn mới phải để tâm đến laziness; tất nhiên làm vậy thì interpreter sẽ phải lục hệ thống file tại thời điểm import và cũng có nhược điểm khácNếu mọi người đều có thể dùng lazy import nhiều mà không gặp vấn đề gì lớn thì đáng ra lazy phải là mặc định, còn <i>eager</i> mới là keyword tùy chọn; kiểu thay đổi mô hình như vậy không phải chưa từng có trong Python; nhiều cú pháp từng tạo list eagerly ở v2 đã chuyển thành generator ở v3 mà cũng đâu có vấn đề gì đáng kể
Nếu có một cờ command line để biến toàn bộ import module trong Python thành lazy thì tôi chắc chắn sẽ dùng; thực tế ngoài script hoặc code thật sự đơn giản ra thì side effect khi load module là một pattern rất nên tránh
Tôi không nghĩ việc để phía module quyết định có lazy loading hay không là hợp lý; chỉ caller mới biết mình có cần lazy load hay không, nên để phía code import đưa ra tùy chọn sẽ hợp lý hơn; module nào cũng có thể được lazy load, và kể cả có side effect thì caller vẫn có thể muốn trì hoãn cả phần đó
Tôi muốn có cách khai báo tùy chọn lazy loading bằng regex trong
pyproject.tomlTrước đây mỗi lần có tính năng mới như type hint, walrus, asyncio, dataclasses v.v. thì người ta cũng nêu ra những lo ngại tương tự, nhưng thực tế đâu có quá nhiều người đồng loạt dùng hết hay thay đổi toàn bộ pattern cũ; nhiều người dùng vẫn chỉ dùng Python ở mức hiện đại hóa của Python 2.4, và như vậy vẫn đủ năng suất; 20 năm qua vẫn chạy tốt, nên tôi nghĩ sẽ không có vấn đề quá lớn
Nếu ai quan tâm thì xin giới thiệu lazyimp, thư viện triển khai lazy import dưới dạng context manager rất tiện; thường chỉ cần bọc câu lệnh import trong một khối
withlà được nên cũng tương thích tốt với công cụ hiện có; khi cần debug thì có thể dễ dàng chuyển lại sang eager import; nó dùng cext để thayf_builtinscủa frame nên mạnh hơn cả hook của importlib; không hoàn hảo nhưng cũng có bản thread-safe và bản global handler; lúc đầu tôi cũng dè dặt nhưng giờ gần như đã chuyển phần lớn codebase sang dùng nó, và trên thực tế không gặp vấn đề gì cả (ngoại trừ chuyện không để ý đăng ký xử lý theo module), còn cảm nhận tốc độ thì cải thiện cực lớn nên rất hài lòngViệc các linter Python ép phải đặt import ở đầu file thực sự rất phiền; mỗi lần dùng cách lazy import hiển nhiên nhất là lại dính lỗi lint; chuyện này không chỉ đơn thuần là vấn đề hiệu năng; ví dụ khi cần một thư viện đặc thù theo nền tảng, ta có thể chỉ muốn import nó trên đúng nền tảng đó, nhưng nếu bị ép import ở đầu file thì có khi việc import thất bại luôn
Khi đó tôi nghĩ chỉ còn cách sửa linter thôi
Phần lớn linter có thể bỏ qua bằng chú thích như
#noqa E402Làm như vậy sẽ thay thế meta path finder bằng một wrapper bọc quanh nó, rồi thay loader bằng LazyLoader; khi import được thực thi thì tên module thực tế sẽ được bind thành
<class 'importlib.util._LazyModule'>, và khi truy cập thuộc tính thì module thật mới được load; mã thử nghiệm:Tuy nhiên tôi không biết chính xác từ "mostly" trong PEP có ý nghĩa gì
Có vẻ người ta đang đánh giá thấp rủi ro về thread safety của lazy import; không thể dự đoán import sẽ chạy lúc nào, trên thread nào, đang giữ lock nào, và ngoài importer lock thì không thể đảm bảo gì; trước đây dù code nguy hiểm có chạy ở thời điểm import module thì phần lớn cũng chỉ xảy ra trong quá trình khởi tạo đơn luồng nên chưa phải vấn đề lớn; nếu chuyển sang lazy thì lỗi sẽ bật ra theo kiểu thật sự không thể đoán trước (Heisenbug); import ở cấp hàm cũng có khả năng gặp chuyện này, nhưng ít ra vẫn còn tính dự đoán là nó sẽ chạy ngay ở đầu đoạn mã tường minh
Cảm giác đây là một tính năng tốt, giải thích cũng dễ hiểu, có use case thực tế và phạm vi phù hợp (dùng global, hoặc dạng keyword đơn giản), tôi thích
Trong các PEP gần đây, với tư cách người dùng thì tôi thấy đây là cái gọn gàng nhất; sau khi đi qua quá trình syntax bikeshedding truyền thống này, tôi khá mong chờ kết quả thực tế
Tôi nghĩ đây là một PEP được chuẩn bị rất kỹ: kiểm chứng với trường hợp thực tế và edge case, có thỏa hiệp phù hợp, cách tiếp cận không quá đà, và đã được mài giũa nhiều lần; nhất là vì nó đụng vào hệ thống cốt lõi của một ngôn ngữ lớn với đủ kiểu cộng đồng trên toàn thế giới, nơi chỉ cần sơ sẩy là rất nguy hiểm, nên xét đến độ khó đó thì càng thấy ấn tượng
Mong là họ đã rút ra đầy đủ bài học từ lý do PEP-690 bị từ chối; ở codebase của chúng tôi cũng từng thử tự triển khai kiểu tính năng này, nhưng chưa lần nào nó hoạt động đủ tốt để dùng được
Lazy import nguy hiểm ở chỗ dễ tạo ra lỗi runtime bất ngờ trong các dịch vụ chạy lâu; nó trông như một lợi thế về khởi động nhanh, nhưng cái giá là phải chấp nhận khả năng việc thực thi code bị dừng giữa chừng vì lỗi import; thêm vào đó còn có thể xuất hiện các edge case khiến ta không thể chắc chắn ngay từ lúc chương trình khởi động rằng rốt cuộc những gì sẽ được import
Dù vậy, đây là một vấn đề thật sự cần phải giải quyết; không chỉ là tốc độ startup, mà Python startup khi có dependency lớn thì chậm đến vô lý; các dự án lớn cũng không thể bundle mọi thư viện nặng mà không phải ai cũng dùng, nên các lập trình viên đã phải dùng tới những cách lách còn kỳ quặc hơn, mà bản thân chúng lại kéo theo các vấn đề vô lý khác; chỉ riêng việc đỡ phải lặp đi lặp lại hoặc che giấu import ở cấp hàm thôi cũng đã là bước tiến lớn rồi; và đây vẫn chỉ đang được đề xuất như một tính năng ngôn ngữ tùy chọn
Có thể giảm thiểu rủi ro khá nhiều bằng kiểm thử tự động, và cái giá đó xứng đáng để đổi lấy startup nhanh hơn; thời gian startup tuyệt đối không chỉ là vấn đề "bề ngoài"; tôi từng gặp chuyện này trong một Django monolith, nơi chỉ vài thư viện nặng đã khiến mọi management command, test, và mỗi lần reload container đều phải chờ thêm 10–15 giây; chuyển sang lazy import để defer đi thì khác biệt cực kỳ lớn
Chúng tôi có xu hướng thích các import tường minh ở đầu file, vì như vậy các vấn đề dependency sẽ lộ ra ngay khi chương trình bắt đầu; nếu dùng lazy import thì sự bất tiện là có thể chỉ phát hiện ra vấn đề khi một code path cụ thể được chạy tới (có lẽ vài giờ, vài ngày sau)
Phần lớn thời gian thực ra bị tiêu tốn vào việc import rồi unload các vendor module mà thực tế chẳng dùng đến (ví dụ riêng các module liên quan đến Requests đã gần 100 cái); sau khi chẩn đoán thì thấy có hơn 500 module đang bị import không cần thiết
Tôi không hiểu vì sao các code generator cũng ngày càng sinh ra nhiều mã đặt local import trong hàm thay vì import ở đầu file; tôi không muốn khuyến khích pattern đó, vì nó làm khó việc nắm bắt dependency của module, và còn tăng nguy cơ phát sinh circular dependency về sau
Tôi vẫn chưa đọc hết PEP, nhưng nghĩ rằng sẽ rất tốt nếu có thể xác thực dependency bằng cờ command line hoặc công cụ bên ngoài, kiểu như các công cụ đi cùng type hint vậy
Tôi tò mò "chúng tôi" ở đây chính xác là chỉ ai
Có phải đây là thứ nên được bao phủ bằng test không?