Gọi Thư Viện C từ Python với ctypes và cffi: Tăng Tốc Code CPU-Intensive Không Cần Cython

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

2 Giờ Sáng và Script Python Của Bạn Là Nút Cổ Chai

Cảnh báo bắn lúc 02:17. Một pipeline xử lý dữ liệu với 80.000 bản ghi mỗi batch đang chạy mất 94 giây. SLA yêu cầu 20 giây. Nhìn vào đồ thị Datadog là thấy ngay thủ phạm: một hàm Python thực hiện custom bitwise encoding trên binary buffer, được gọi 80.000 lần trong một vòng lặp chặt.

Bạn đã thử mọi thứ — vectorization với numpy chỗ nào có thể, functools.lru_cache, thậm chí cả multiprocessing. Vẫn không xuống được 30 giây vì nút cổ chai là phép tính CPU thuần bên trong vòng interpreter của Python, từng byte một dưới GIL.

Giải pháp thực sự hiệu quả đêm đó — và tôi đã áp dụng ổn định trên môi trường production từ đó đến nay — là gọi một hàm C nhỏ từ Python bằng ctypes. Không cần build system mới, không cần cài Cython, không cần viết boilerplate C extension module. Chỉ cần một shared library và vài dòng Python.

Nguyên Nhân Gốc Rễ: Tại Sao Python Thua Trên CPU Thuần

Python không chậm vì kém — nó chậm trên vòng lặp CPU-bound vì mọi phép tính đều phải đi qua eval loop của interpreter. Mỗi bytecode instruction đều có overhead: type dispatch, reference counting, GIL acquisition. Với I/O-bound hay orchestration logic, overhead đó không đáng kể. Với vòng lặp số học chặt chạy hàng triệu lần, nó tích lũy rất nhanh.

Global Interpreter Lock (GIL) khiến threading vô dụng với CPU work. multiprocessing có giúp nhưng lại tốn chi phí serialization liên process và nhân bộ nhớ. Điều thực sự cần là thoát khỏi Python hoàn toàn ở đoạn hot path — chạy machine code đã biên dịch rồi quay lại với kết quả.

Cả ctypescffi đều cho phép làm vậy. Chúng cho phép Python gọi vào code C đã biên dịch lúc runtime, không cần bước biên dịch trung gian nào từ phía bạn.

Các Lựa Chọn Hiện Có

ctypes — Có Sẵn Trong Python, Không Cần Dependency

ctypes là một phần của thư viện chuẩn Python từ phiên bản 2.5. Nó tải shared library (.so trên Linux, .dll trên Windows, .dylib trên macOS) và gọi trực tiếp các hàm được export. Không cần biên dịch Python extension module. Bạn chỉ cần code C đã được biên dịch thành shared library — bất kỳ hệ thống nào có gcc hoặc clang đều làm được trong một lệnh.

Dùng ctypes khi gọi vào thư viện hệ thống có sẵn (libc, libm, libssl, v.v.) hoặc một hàm C nhỏ tự biên dịch.

cffi — Pythonic Hơn, Xử Lý Cấu Trúc Phức Tạp Tốt Hơn

cffi (C Foreign Function Interface) là thư viện bên thứ ba với cách tiếp cận khác: bạn dán trực tiếp các khai báo C vào Python, và cffi xử lý phần binding. Các API phức tạp với struct, callback và pointer arithmetic trở nên dễ đọc hơn nhiều theo cách này. cffi còn hỗ trợ chế độ “out-of-line” — binding được biên dịch một lần và cache lại, giúp import nhanh trong production.

Cài đặt bằng pip install cffi. Thư viện được bảo trì tốt, chạy trên mọi nền tảng chính và không kéo theo dependency lạ — thêm vào requirements.txt rất đơn giản.

Tại Sao Không Dùng Cython?

Cython rất tốt nhưng đòi hỏi bước build can thiệp vào packaging pipeline. Bạn phải viết file .pyx, chạy cython để sinh code C, biên dịch với C compiler nhắm vào ABI của CPython, rồi phân phối file .so kết quả. Trên server bạn tự quản lý thì ổn. Trên fleet container đa dạng hay team không phải ai cũng có build toolchain hoạt động, đó là điểm ma sát. ctypescffi bỏ qua tất cả điều đó — bạn đang gọi vào binary đã biên dịch sẵn.

ctypes Trong Thực Tế: Gọi Thư Viện Hệ Thống Trước

Trước khi viết C, hãy thử ctypes với libm — thư viện toán học C có sẵn trên mọi hệ thống Linux:

import ctypes
import math

# Tải shared library
libm = ctypes.CDLL("libm.so.6")

# Khai báo kiểu trả về (mặc định là c_int)
libm.sqrt.restype = ctypes.c_double
libm.sqrt.argtypes = [ctypes.c_double]

result = libm.sqrt(ctypes.c_double(144.0))
print(result)  # 12.0

Ba dòng để tải, hai dòng khai báo kiểu, một dòng để gọi. Áp dụng pattern tương tự cho hàm C tự viết. Tạo một file C tối giản:

// encode.c
#include <stdint.h>

uint32_t encode_buffer(const uint8_t *buf, int len) {
    uint32_t checksum = 0;
    for (int i = 0; i < len; i++) {
        checksum ^= (buf[i] << (i % 8));
        checksum = (checksum << 1) | (checksum >> 31);
    }
    return checksum;
}

Biên dịch thành shared library bằng một lệnh:

gcc -O2 -shared -fPIC -o encode.so encode.c

Tải và gọi từ Python:

import ctypes

lib = ctypes.CDLL("./encode.so")
lib.encode_buffer.restype = ctypes.c_uint32
lib.encode_buffer.argtypes = [
    ctypes.POINTER(ctypes.c_uint8),
    ctypes.c_int
]

data = bytes(range(256)) * 100  # 25.600 byte
buf = (ctypes.c_uint8 * len(data)).from_buffer_copy(data)

result = lib.encode_buffer(buf, len(data))
print(f"Checksum: {result:#010x}")

Đêm xảy ra sự cố đó, phiên bản thuần Python mất 94 giây cho toàn bộ batch. Sau khi chuyển hàm hot vào 15 dòng C và gọi qua ctypes, batch chạy còn 6,8 giây. Logic như cũ, output như cũ, test suite vẫn pass.

cffi Trong Thực Tế: API Gọn Hơn Cho Binding Phức Tạp

Khi C API dùng struct, hoặc bạn muốn binding dễ đọc hơn, cffi là lựa chọn tốt hơn. Đây là cùng hàm encode được wrap bằng inline mode của cffi:

import cffi

ffi = cffi.FFI()

# Dán trực tiếp khai báo C — cffi tự parse
ffi.cdef("""
    uint32_t encode_buffer(const uint8_t *buf, int len);
""")

lib = ffi.dlopen("./encode.so")

data = bytes(range(256)) * 100
buf = ffi.new("uint8_t[]", data)

result = lib.encode_buffer(buf, len(data))
print(f"Checksum: {result:#010x}")

Lệnh ffi.new() cấp phát buffer do C quản lý — cffi xử lý memory layout. Với API có struct lồng nhau, cách này mở rộng tốt hơn nhiều so với khai báo field thủ công trong ctypes.

Trên production, ưu tiên dùng out-of-line ABI mode của cffi. Định nghĩa binding trong một script build riêng chạy một lần và cache kết quả thành file .so:

# build_bindings.py (chạy một lần)
import cffi
ffi = cffi.FFI()
ffi.cdef("uint32_t encode_buffer(const uint8_t *buf, int len);")
ffi.set_source("_encode_binding", '#include "encode.h"',
               sources=["encode.c"],
               extra_compile_args=["-O2"])

if __name__ == "__main__":
    ffi.compile(verbose=True)
python build_bindings.py

Sau đó, import nhanh và không có overhead biên dịch lúc runtime:

# main.py
from _encode_binding import ffi, lib

buf = ffi.new("uint8_t[]", data)
result = lib.encode_buffer(buf, len(data))

Những Điều Cần Chú Ý

Type Mismatch Gây Crash Ngay

ctypes và cffi không bảo vệ bạn khỏi việc truyền sai kiểu vào hàm C. Pointer sai kiểu sẽ làm interpreter crash với segfault — không có Python traceback, chỉ là process chết. Luôn khai báo argtypesrestype tường minh trong ctypes. cdef() của cffi tự động làm điều này vì nó parse khai báo C thật.

Quản Lý Bộ Nhớ Là Trách Nhiệm Của Bạn

Nếu hàm C trả về pointer đến vùng nhớ được cấp phát trên heap, bạn là chủ sở hữu. Garbage collector của Python không biết pointer đó tồn tại. Bạn phải gọi hàm free tương ứng — không thì leak memory trên production, một cách thú vị để đón 2 giờ sáng tiếp theo.

# Luôn ghép alloc với free khi C sở hữu bộ nhớ
ptr = lib.create_buffer(1024)
try:
    # ... dùng ptr ...
    pass
finally:
    lib.free_buffer(ptr)

Giải Phóng GIL Để Chạy Song Song

ctypes tự động giải phóng GIL trong khi gọi hàm C. Điều này có nghĩa bạn có thể chạy code C CPU-bound song song bằng Python thread — điều không thể làm với CPU work thuần Python. Nếu workload của bạn embarrassingly parallel, kết hợp ctypes với concurrent.futures.ThreadPoolExecutor để đạt scaling gần tuyến tính trên nhiều core.

ctypes vs cffi: Khi Nào Dùng Cái Nào

  • Dùng ctypes khi: gọi vào thư viện hệ thống có sẵn, viết binding nhanh một lần, hoặc khi muốn không thêm dependency nào.
  • Dùng cffi khi: C API có struct, callback hay pointer type phức tạp; khi muốn binding đọc như khai báo C; hoặc khi cần out-of-line compiled mode cho hiệu năng production.
  • Tránh cả hai khi: wrap C++ API lớn với class và template — hãy dùng pybind11 thay thế.

Thực Tế Benchmark

Với vòng lặp encoding từ sự cố production:

  • Vòng lặp Python thuần trên 80.000 bản ghi: 94 giây
  • Xấp xỉ vectorized bằng NumPy: 31 giây (thuật toán khác, không khớp chính xác)
  • ctypes gọi C với -O2: 6,8 giây
  • cffi out-of-line với -O3: 5,9 giây

Từ đó đến nay, cách tiếp cận này đã chạy trên production ở ba service riêng biệt. Không có crash. Không leak memory sau khi bắt được hai lỗi trong code review. Bản thân code C đơn giản đến mức có tỷ lệ lỗi thấp hơn Python mà nó thay thế — kết quả không làm ai ngạc nhiên sau khi đo.

Toàn bộ giải pháp — file C, lệnh gcc và Python binding — gói gọn trong một git commit. Đồng nghiệp có thể đọc được, CI có thể build được, và container production tải file .so lúc runtime. Không cần thay đổi build system.

Share: