Generator và yield trong Python: xử lý dữ liệu lớn không tốn RAM
1. Tổng quan
Khi xử lý file log vài GB hay stream hàng triệu bản ghi từ database, cách viết code “đọc hết vào list rồi xử lý” sẽ khiến chương trình ngốn RAM và có thể crash. Bài này giúp bạn hiểu generator và yield hoạt động thế nào, so sánh với list thông thường, và áp dụng vào các bài toán thực tế như đọc file lớn, pipeline xử lý dữ liệu. Đọc xong, bạn sẽ viết được generator đúng cách và biết khi nào không nên dùng nó.
2. Vấn đề với cách tiếp cận truyền thống
Giả sử bạn cần đọc một file log 5GB và đếm số dòng chứa từ “ERROR”:
def count_errors_bad(filepath: str) -> int:
with open(filepath, "r") as f:
lines = f.readlines() # nạp TOÀN BỘ file vào RAM
return sum(1 for line in lines if "ERROR" in line)
f.readlines() đọc toàn bộ nội dung file thành một list các string trong bộ nhớ. Với file 5GB, Python sẽ cấp phát ~5GB+ RAM (cộng thêm overhead của object) trước khi bắt đầu đếm. Nếu máy chỉ có 4GB RAM, chương trình sẽ bị MemoryError hoặc làm hệ thống treo vì swap.
Vấn đề cốt lõi: bạn chỉ cần xử lý từng dòng một, nhưng code lại buộc máy phải giữ tất cả các dòng cùng lúc.
3. Generator là gì?
Generator là một loại iterable đặc biệt: nó sinh giá trị theo yêu cầu (lazy evaluation) thay vì tính toán và lưu trữ tất cả trước. Thay vì trả về một list đầy đủ, generator trả về từng giá trị một, và chỉ tính giá trị tiếp theo khi được hỏi tới.
def count_errors_good(filepath: str) -> int:
with open(filepath, "r") as f:
return sum(1 for line in f if "ERROR" in line)
Ở đây f (file object) bản thân nó đã là một iterator — nó đọc từng dòng một khi vòng lặp yêu cầu, không nạp cả file vào RAM. Bộ nhớ sử dụng chỉ là một dòng tại một thời điểm, bất kể file 5GB hay 50GB.
4. Từ khóa yield: cách tạo generator function
Bạn có thể tự viết generator bằng cách dùng yield trong một function. Khi function chứa yield, Python tự động biến nó thành generator function — gọi nó không chạy code ngay mà trả về một generator object.
def read_large_file(filepath: str):
with open(filepath, "r") as f:
for line in f:
yield line.strip()
yield line.strip()tạm dừng function tại đây, trả giá trịline.strip()ra ngoài.- Lần gọi
next()tiếp theo, function tiếp tục chạy từ chỗ dừng lại, không chạy lại từ đầu. - Trạng thái của function (biến cục bộ, vị trí con trỏ file) được giữ nguyên giữa các lần
yield.
Gọi thử:
gen = read_large_file("access.log")
print(type(gen))
print(next(gen))
print(next(gen))
<class 'generator'>
127.0.0.1 - GET /index.html 200
127.0.0.1 - GET /style.css 200
Lưu ý: dòng gen = read_large_file(...) không mở file, không đọc gì cả. Code bên trong chỉ chạy khi bạn gọi next(gen) lần đầu tiên.
5. So sánh RAM: generator vs list
Để thấy rõ sự khác biệt, hãy dùng module sys để đo kích thước object:
import sys
def squares_list(n: int) -> list[int]:
return [i * i for i in range(n)]
def squares_gen(n: int):
for i in range(n):
yield i * i
big_list = squares_list(1_000_000)
big_gen = squares_gen(1_000_000)
print(f"List size: {sys.getsizeof(big_list):,} bytes")
print(f"Generator size: {sys.getsizeof(big_gen):,} bytes")
List size: 8,448,728 bytes
Generator size: 208 bytes
List chứa 1 triệu phần tử chiếm gần 8.5MB, trong khi generator chỉ là một object nhỏ 208 bytes — vì nó không lưu giá trị nào, chỉ lưu trạng thái để tính giá trị tiếp theo khi cần.
Lưu ý: Generator chỉ duyệt được một lần. Sau khi duyệt hết, gọi lại
next()sẽ raiseStopIteration. Nếu cần duyệt nhiều lần, bạn phải tạo generator mới bằng cách gọi lại function.
6. Generator expression: cú pháp gọn hơn
Tương tự list comprehension nhưng dùng ngoặc tròn thay vì ngoặc vuông, bạn có generator expression — không cần định nghĩa function riêng:
numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
even_squares_gen = (n * n for n in numbers if n % 2 == 0)
print(sum(even_squares_gen))
220
Dấu ngoặc () thay vì [] khiến Python tạo generator thay vì list ngay lập tức. Cách này tiện khi bạn chỉ cần dùng giá trị một lần, ví dụ truyền thẳng vào sum(), max(), hay any().
7. Xây dựng pipeline xử lý dữ liệu bằng generator
Điểm mạnh của generator là có thể chuỗi nhiều bước xử lý mà không tạo list trung gian nào. Giả sử bạn cần: đọc file CSV đơn hàng → lọc đơn có status = "PAID" → tính tổng amount.
import csv
def read_orders(filepath: str):
with open(filepath, newline="") as f:
reader = csv.DictReader(f)
for row in reader:
yield row
def filter_paid(orders):
for order in orders:
if order["status"] == "PAID":
yield order
def extract_amount(orders):
for order in orders:
yield float(order["amount"])
def total_paid_amount(filepath: str) -> float:
orders = read_orders(filepath)
paid_orders = filter_paid(orders)
amounts = extract_amount(paid_orders)
return sum(amounts)
Mỗi hàm read_orders, filter_paid, extract_amount đều là generator — chúng không xử lý toàn bộ dữ liệu ngay, mà chỉ “nối” vào nhau thành một pipeline. Khi sum(amounts) gọi next(), giá trị được kéo (pull) qua từng bước: đọc 1 dòng CSV → kiểm tra status → convert sang float → cộng dồn. Tại một thời điểm, chỉ một bản ghi tồn tại trong bộ nhớ, dù file CSV có 10 dòng hay 10 triệu dòng.
7.1. Test pipeline với pytest
import pytest
from pathlib import Path
@pytest.fixture
def orders_csv(tmp_path: Path) -> str:
content = (
"order_id,amount,status\n"
"1,100.0,PAID\n"
"2,50.0,PENDING\n"
"3,200.0,PAID\n"
)
file_path = tmp_path / "orders.csv"
file_path.write_text(content)
return str(file_path)
def test_total_paid_amount(orders_csv):
result = total_paid_amount(orders_csv)
assert result == pytest.approx(300.0)
tmp_path là fixture có sẵn của pytest, tạo thư mục tạm riêng cho mỗi test. Test này tạo file CSV giả với 3 đơn hàng, chỉ 2 đơn PAID, và kiểm tra tổng đúng bằng 300.0.
8. yield from: ủy quyền cho generator khác
Khi một generator cần gọi và lồng kết quả từ generator khác, yield from giúp code gọn hơn thay vì viết vòng lặp thủ công.
def read_file_lines(filepath: str):
with open(filepath) as f:
for line in f:
yield line.strip()
def read_multiple_files(filepaths: list[str]):
for filepath in filepaths:
yield from read_file_lines(filepath)
yield from read_file_lines(filepath) tương đương với viết for line in read_file_lines(filepath): yield line, nhưng ngắn gọn hơn và xử lý đúng cả trường hợp generator con có return giá trị. Hàm read_multiple_files cho phép bạn duyệt qua nhiều file log như thể chúng là một stream liên tục, không cần gộp list.
9. Gửi giá trị vào generator bằng send()
Generator không chỉ sinh giá trị ra ngoài — bạn còn có thể gửi giá trị vào bên trong nó bằng send(), biến nó thành coroutine đơn giản.
def running_average():
total = 0.0
count = 0
average = None
while True:
value = yield average
total += value
count += 1
average = total / count
avg_gen = running_average()
next(avg_gen) # khởi động generator, chạy tới yield đầu tiên
print(avg_gen.send(10)) # gửi 10 vào, average = 10/1
print(avg_gen.send(20)) # gửi 20 vào, average = 30/2
print(avg_gen.send(30)) # gửi 30 vào, average = 60/3
10.0
15.0
20.0
Dòng value = yield average làm hai việc: trả average ra ngoài, đồng thời chờ nhận giá trị gửi vào qua send() để gán cho value. Bạn phải gọi next(avg_gen) một lần đầu để đưa generator tới điểm yield đầu tiên — nếu send() ngay từ đầu khi generator chưa chạy, Python sẽ raise TypeError.
Lưu ý: Dùng
send()khiến code khó đọc hơn generator thông thường. Trong thực tế, các framework nhưasynciođã trừu tượng hóa cơ chế này; bạn hiếm khi cần tự viếtsend()trừ khi làm thư viện riêng.
10. Khi nào KHÔNG nên dùng generator
Generator không phải lúc nào cũng tốt hơn list:
- Cần truy cập ngẫu nhiên hoặc duyệt nhiều lần: generator không hỗ trợ
gen[5]hay duyệt lại từ đầu. Nếu bạn cầnlen(), slicing, hoặc duyệt dữ liệu 2-3 lần, list phù hợp hơn. - Dữ liệu nhỏ, không đáng lo RAM: với vài nghìn phần tử, overhead quản lý state của generator đôi khi chậm hơn list comprehension vì list được tối ưu ở tầng C.
- Cần parallel hoặc chia việc cho nhiều worker: list dễ chia nhỏ (
chunks) để gửi cho multiprocessing; generator phải được “vật chất hóa” một phần trước khi chia. - Debug khó hơn: in một list ra thấy ngay toàn bộ giá trị; in generator chỉ thấy
<generator object ... at 0x...>, muốn xem giá trị phải duyệt (và duyệt xong thì generator “cạn”, không dùng lại được).
Nếu không chắc, nguyên tắc đơn giản: dùng list khi dữ liệu vừa với RAM và cần dùng lại nhiều lần; dùng generator khi dữ liệu lớn, chỉ xử lý tuần tự một lần, hoặc xây dựng pipeline streaming.
11. Kết luận
Generator và yield giải quyết đúng bài toán: xử lý dữ liệu lớn mà không cần nạp hết vào RAM, nhờ cơ chế lazy evaluation — chỉ tính giá trị khi được yêu cầu. Bạn đã thấy cách viết generator function, generator expression, nối nhiều generator thành pipeline bằng yield from, và cả cách gửi dữ liệu ngược vào generator bằng send(). Hãy nhớ đánh đổi: generator tiết kiệm bộ nhớ nhưng đổi lại bạn mất khả năng duyệt lại, truy cập ngẫu nhiên, và đôi khi code khó đọc hơn. Chọn đúng công cụ cho đúng bài toán, không phải cứ thấy “tiết kiệm RAM” là dùng generator ở mọi nơi.