Khoảng thời gian sau đi làm 1-2 năm là thời điểm sung sức nhất với việc lập trình, tôi có thể tưởng tượng ra trong đầu nhiều cách để giải quyết một vấn đề, thậm chí nghĩ xem nên làm theo cách nào mới "đẳng cấp" nhất. Suy nghĩ thật đơn giản: Cách nào được nhiều người khuyên, nhiều người áp dụng... hẳn là cách tốt nhất. Một logic ban đầu trông không có gì nhưng sau khi đi qua vài lớp suy nghĩ đã dày cộp như chiếc bánh burger, bởi một hàm phải đi qua vài lớp bọc để sẵn sàng "cover" nhiều trường hợp sau này.
Viết code có thể nói là một công việc thú vị, giải quyết được một bài toán có thể khiến tâm trạng vui lên cả ngày, mang đến nhiều câu chuyện để chém gió với đồng nghiệp, phân tích, mổ xẻ vấn đề ngỡ chưa ai biết. Nhưng hầu hết câu chốt đều đặt nghi vấn: "Thế còn hiệu năng thì sao?". Mã viết ra chạy được là một chuyện, nhưng đã bao giờ bạn đặt câu hỏi làm thế nào để biết mã mình viết ra đủ tốt hay chưa? Nhiều người nghĩ rằng chỉ cần tuân theo thực hành tốt nhất (Best Practices) thì ắt hẳn nó sẽ chạy nhanh nhất. Điều đó đúng nhưng chưa đủ, nếu ai cũng chắc chắn mã mình viết ra là tốt thì đâu đâu cũng có những hệ thống hoàn hảo. Mã chạy nhanh hay không phụ thuộc vào rất nhiều yếu tố chứ không chỉ riêng cách viết. Người ta thường không dựa vào cảm tính để đánh giá một logic là nhanh hay chậm, mà muốn chứng minh được phải dựa trên số liệu, hay ít nhất là cần phải biết đoạn mã nào đang tốn thời gian xử lý. Sau khi mọi thứ bày ra trước mắt từ đó mới tìm ra cách tối ưu.
Profiling là phương pháp được sử dụng nhiều để đo xem ứng dụng thực sự tiêu tốn CPU, bộ nhớ hoặc thời gian ở đâu, thay thế việc tối ưu theo cảm tính bằng dữ liệu. Mỗi ngôn ngữ lập trình thường có sẵn công cụ hỗ trợ làm việc này. Trong Node.js chúng ta có sẵn công cụ profiler inside V8 tích hợp trong V8 vốn là "vi xử lý trung tâm" của Node.
Một tác vụ đồng bộ nặng có thể chặn event loop, làm tăng độ trễ và giảm khả năng xử lý đồng thời dù nhìn sơ qua mã nguồn ứng dụng không thấy vấn đề gì. Khi nói về bài toán tối ưu hóa hiệu năng thường không dựa vào chiến lược lược bớt thành phần râu ria cho là ít quan trọng. Bởi vì nó thiên về cảm tính, chưa kể có thể gây lỗi khi chương trình đã chạy trong môi trường sản xuất, muốn biết có chậm hay không bắt buộc chúng ta phải có kỹ thuật đo đếm.
Node.js có một bài viết giới thiệu về cách sử dụng profiling bằng chính công cụ tích hợp sẵn rất dễ hiểu: Profiling Node.js Applications. Bạn đọc có thể tham khảo, trong bài viết này tôi chỉ sơ lược lại các ý chính.
Nội dung trình bày một máy chủ API có điểm cuối (endpoint) /newUser, trong endpoint đó gọi một hàm crypto.pbkdf2Sync(), nhìn sơ qua cho thấy không có gì phức tạp, hàm pbkdf2Sync cần thiết để mã hóa mật khẩu thành chuỗi ký tự trước khi lưu vào cơ sở dữ liệu. Giả sử một ngày máy chủ liên tục báo quá tải và bạn nghi ngờ /newUser có thể là vấn đề thì phải làm thế nào? Chạy một vài truy vấn đến đó xem thời gian nhận được phản hồi thực tế là bao nhiêu? Cũng là một cách! Tuy nhiên vẫn còn cách tốt hơn là chẩn đoán bằng profiling.
Nhưng trên môi trường production không dễ gì chạy gỡ lỗi (debug), cho nên cách tiếp cận đơn giản hơn là chạy ở dưới máy local bằng cách sử dụng công cụ ApacheBench để giả lập truy vấn đến endpoint đó. Ví dụ:
$ ab -k -c 20 -n 250 http://localhost:3000/newUser`.
Lệnh trên tương đương với chạy 20 luồng gửi truy vấn đồng thời, tổng lần gọi đủ 250 thì kết thúc.
Trước khi chạy lệnh ab cần khởi động node với cờ --prof để bật profiling, chạy node --prof app.js, sau đó chạy lệnh ab giống như trên, kết quả thu được ghi ra tệp có dạng <isolate-v8.log> nhưng chúng ta vẫn chưa đọc được, cần xử lý log bằng cách chạy node --prof-process <isolate-v8.log>, tập trung đọc [Summary], [C++], cùng [Bottom up (heavy) profile]. Kết quả cho thấy phần lớn CPU nằm ở hàm node::crypto::PBKDF2.
[Summary]:
ticks total nonlib name
79 0.2% 0.2% JavaScript
36703 97.2% 99.2% C++
7 0.0% 0.0% GC
767 2.0% Shared libraries
215 0.6% Unaccounted
[C++]:
ticks total nonlib name
19557 51.8% 52.9% node::crypto::PBKDF2(v8::FunctionCallbackInfo<v8::Value> const&)
4510 11.9% 12.2% _sha1_block_data_order
3165 8.4% 8.6% _malloc_zone_malloc
Có thể thấy 97.2% thời gian xử lý là dành cho C++, trong đó 51.8% là hàm có tên node::crypto::PBKDF2. Ánh xạ vào trong mã thì nó chính là hàm pbkdf2Sync. Đến đây chúng ta chợt nghĩ pbkdf2Sync có hậu tố Sync thường là hàm đồng bộ, chạy trong luồng chính có thể chạy chặn vòng lặp sự kiện (event loop). Vậy khả năng vấn đề nằm ở đây!
Thay pbkdf2Sync() bằng pbkdf2() là hàm tương tự nhưng chạy bất đồng bộ để không chặn event loop trong lúc tính toán. Sau đó chạy lại chương trình và đo lại hiệu năng. Đối chiếu số liệu: thông lượng tăng từ khoảng 5,3 lên 19,5 req/s, còn độ trễ trung bình giảm từ khoảng 3,75 xuống 1,03 giây.
Như vậy rõ ràng chúng ta đã xác định đúng vấn đề khi dựa vào số liệu (mặc dù nhìn qua ai cũng biết, nhưng vì là ví dụ nên cần đơn giản, áp dụng trong thực tế cũng chỉ lặp qua các bước tương tự). Đến đây chợt có câu hỏi dành cho bạn đọc, tại sao thay đổi từ crypto.pbkdf2Sync() sang pbkdf2() thì Node lại xử lý được nhiều truy vấn cùng lúc hơn? Chẳng phải các phép tính hash đều phải được thực hiện hay sao?
Profiling bằng công cụ profiler có sẵn trong V8 chỉ phù hợp với quá trình debug, tức là nghi ngờ rồi đo đạc để xác định chính xác nguyên nhân chứ không thể theo dõi trực tiếp trong production. Vì vậy thường phải kết hợp tracing, metrics và logs để tạo thành bộ công cụ giúp nhận biết vấn đề hiệu năng từ sớm.
Trong production, người ta thường kết hợp APM và continuous profiler giúp quan sát liên tục, liên kết với endpoint, trace, phiên bản và lỗi. Hãy tưởng tượng đó như là màn hình hiển thị số liệu dày đặc các chỉ số của ứng dụng. Một trong những ví dụ điển hình chính là OpenTelemetry kết hợp với các nền tảng như Datadog, New Relic, Elastic hoặc Grafana/Pyroscope. OpenTelemetry đóng vai trò như là lớp tiêu chuẩn hóa telemetry nằm giữa ứng dụng và các hệ thống observability, trong khi Datadog, Grafana, New Relic... cung cấp nền tảng cho một hệ thống monitoring.
Tuy nhiên profiler inside V8 không phải vì thế mà trở nên vô dụng, vẫn cần phải có công cụ nền tảng để đo lường cho các trường hợp mà công cụ khác không thể làm được.