Ngoại lệ (exception) là chủ đề gây tranh cãi bậc nhất trong C++. Một phe nói "try/catch làm chậm chương trình, tránh xa exception". Phe kia dùng throw như một câu lệnh điều khiển luồng bình thường. Cả hai đều hiểu sai cách ngoại lệ thật sự tốn chi phí, và phép đo giải quyết dứt điểm. Trình biên dịch C++ hiện đại dùng mô hình zero-cost exception (dựa trên bảng): đường không ném — cái ta gọi là happy path — gần như không có lệnh thừa nào, kể cả khi bọc trong try/catch; toàn bộ thông tin gỡ ngăn xếp nằm trong một bảng tra ở chỗ khác, chỉ dùng đến khi có ném. Đổi lại, khi throw thật sự xảy ra, cái giá dồn lại rất lớn. Tôi đo trong container gcc:13 trên host ARM để thấy hai đầu của sự đánh đổi này.

Chi phí ném ngoại lệ

Zero-cost khi không ném, rất đắt khi ném

Mô hình zero-cost hoạt động thế này: trình biên dịch sinh mã cho happy path (không ném) không kèm bất kỳ lệnh kiểm tra hay thiết lập nào — nó chạy y như không có try/catch. Thông tin cần để gỡ ngăn xếp (biết chạy destructor nào, nhảy tới catch nào) được đặt trong các bảng nằm ngoài đường thực thi nóng. Khi không có ngoại lệ, các bảng đó không bao giờ được đụng tới — nên bọc try/catch không tốn gì ở đường thường.

Cái giá dồn hết vào lúc throw: CPU phải gỡ ngăn xếp (unwind — đi ngược các khung hàm), tra bảng để tìm landing pad (chỗ xử lý), chạy destructor của mọi đối tượng cục bộ dọc đường, rồi khớp kiểu với các mệnh đề catch. Đây là cả một quy trình phức tạp — đắt hơn một lời gọi/return bình thường hàng trăm đến hàng nghìn lần. Đó là lý do "zero-cost" chỉ đúng cho đường không ném; đường ném thì rất đắt. Tôi đo cả ba: happy path không try/catch, happy path có try/catch, và một throw thật — cộng thêm cách dùng mã lỗi để so.

Đo: try/catch happy path 1,00x, một throw 800 ns (1184 lần)

Tôi chạy một hàm nhỏ (thân x*3+1) hàng trăm triệu lần theo bốn cách, đo lấy min sau warmup:

Host ARM, g++ -O2, thân hàm f = x*3+1:

   HAPPY PATH (không ném bao giờ)          | ns/lần | ghi chú
   ----------------------------------------|--------|------------------------------
   không try/catch                         | 0,675  | gốc
   CÓ try/catch (bọc quanh, không ném)     | 0,676  | 1,00x -> zero-cost thật
   dùng MÃ LỖI (kiểm branch mỗi lần)       | 0,930  | chậm hơn! branch mỗi lời gọi

   ĐƯỜNG NÉM (throw + catch thật)          | ns     | ghi chú
   ----------------------------------------|--------|------------------------------
   một lần throw + catch                    | ~800   | ~0,8 micro giây = 1184x

Đọc ra ba điều. Một (zero-cost là thật): happy path try/catch tốn 0,676 ns, bằng đúng bản không try/catch (0,675 ns) — 1,00x. Bọc code trong try/catch không thêm một nano-giây nào khi không có ngoại lệ. Định kiến "try/catch làm chậm code" sai — với zero-cost EH, sự hiện diện của try/catch là vô hình ở đường thường.

Hai (mã lỗi còn chậm hơn): cách "an toàn" mà nhiều người dùng để né exception — trả mã lỗi và kiểm tra sau mỗi lời gọi — tốn 0,930 ns, chậm hơn cả happy path có try/catch! Vì mỗi lời gọi phải kiểm tra một nhánh (có lỗi không?) dù lỗi gần như không bao giờ xảy ra. Zero-cost EH đẩy chi phí hoàn toàn sang đường ném; mã lỗi rải một chút chi phí lên mọi lời gọi, kể cả đường thường. Đây là điều bất ngờ với người tin "mã lỗi luôn rẻ hơn exception".

Ba (throw cực đắt): một lần throw + catch thật tốn ~800 ns ≈ 0,8 micro giây — gấp 1184 lần một vòng lặp happy-path bình thường. Gỡ ngăn xếp, tra bảng, chạy destructor, khớp catch — tất cả dồn vào một lần ném. Nếu bạn ném trong một vòng lặp nóng hay cho một trường hợp xảy ra thường xuyên, bạn trả cái giá 800 ns đó mỗi lần — thảm họa hiệu năng.

Một lần tôi đo hớ: "try/catch làm chậm" và "exception rẻ, dùng thoải mái"

Tôi vào đo với định kiến bi quan phổ biến: "cứ có try/catch là code chậm đi, nên tránh exception, dùng mã lỗi cho nhanh". Đo phá tan: happy path có try/catch bằng đúng không try/catch (1,00x) — zero-cost là thật. Tệ hơn cho định kiến đó, cách "thay thế" bằng mã lỗi lại chậm hơn (0,930 vs 0,676 ns), vì nó bắt mọi lời gọi kiểm một nhánh, trong khi exception đẩy chi phí sang đường ném mà happy path không đụng tới. Với lỗi hiếm, exception cho happy path nhanh hơn mã lỗi.

Nhưng đo cũng chỉnh niềm tin ngược mà người mê exception dễ có: "exception rẻ mà (zero-cost đấy thôi), cứ dùng throw cho mọi luồng điều khiển, kể cả case bình thường". Sai — "zero-cost" chỉ nói về đường không ném. Một throw thật tốn ~800 ns, gấp 1184 lần một thao tác thường. Nếu bạn dùng exception cho một tình huống xảy ra thường xuyên (ví dụ "không tìm thấy" trong một vòng tra cứu nóng, hay dùng throw để thoát vòng lặp), bạn biến mỗi lần đó thành một cú unwind micro-giây — chậm gấp bội so với một if hay một return. Exception phải dành cho lỗi hiếm và ngoại lệ thật sự, không phải luồng điều khiển thường ngày.

Bài học đo lường: zero-cost EH: happy path (không ném) gần như miễn phí kể cả có try/catch — đo try/catch 0,676 vs không 0,675 ns = 1,00x; thậm chí mã lỗi (branch mỗi lần) còn chậm hơn (0,930 ns). NHƯNG throw dồn hết giá: một throw+catch ~800 ns (~0,8 micro giây) = 1184x một vòng lặp thường (unwind + tra bảng + destructor). 'Try/catch làm chậm code kể cả khi không lỗi' SAI (zero-cost, happy path 1,00x); 'exception rẻ, dùng cho luồng điều khiển thường' SAI (throw cực đắt, chỉ cho lỗi hiếm). Nếu tin "try/catch làm chậm" tôi né exception vô ích và còn dùng mã lỗi chậm hơn; nếu tin "exception rẻ" tôi ném trong đường nóng và tự tạo thảm họa.

Vì sao điều này quan trọng khi lập trình

Hệ quả đầu tiên: dùng exception cho lỗi hiếm và thật sự ngoại lệ — đừng né vì sợ chi phí happy path. File không mở được, kết nối rớt, dữ liệu hỏng: những thứ hầu như không xảy ra trên đường thường là chỗ lý tưởng cho exception, vì happy path zero-cost và bạn không phải rải kiểm tra mã lỗi khắp nơi. Code sạch hơn nhanh hơn ở đường thường.

Hệ quả thứ hai: đừng bao giờ dùng throw cho luồng điều khiển thường xuyên hay trong vòng lặp nóng. "Không tìm thấy phần tử", "hết dữ liệu", "phân tích thất bại trên input người dùng hay gặp" — những case xảy ra thường xuyên thì trả về giá trị/mã lỗi (std::optional, std::expected, mã trả về) và kiểm bằng một nhánh, vì một throw tốn ~800 ns còn một nhánh dự đoán đúng gần như miễn phí. Quy tắc: throw cho cái hiếm, return cho cái thường.

Hệ quả thứ ba là tinh thần đo lường: chi phí ngoại lệ là câu chuyện hai đường — đường vui gần như miễn phí, đường ném cực đắt — nên chọn cơ chế theo tần suất lỗi. Con số mang theo: happy path try/catch = 1,00x (zero-cost); một throw ~800 ns = 1184x một thao tác; mã lỗi tốn một nhánh mỗi lần. Lỗi hiếm -> exception (happy path nhanh); lỗi thường -> mã lỗi/optional (tránh 800 ns throw). Cùng một cơ chế, lý tưởng hay thảm họa chỉ tùy bạn ném hiếm hay thường — và chỉ đo mới cho bạn thấy khoảng cách 1184 lần giữa hai đường.

Thử ba mươi giây

Viết một hàm nhỏ làm một phép tính và gọi nó vài trăm triệu lần trong một vòng lặp, đo thời gian. Rồi bọc lời gọi trong try { ... } catch(...) {} (nhưng hàm không bao giờ ném) và đo lại: bạn sẽ thấy hai con số gần như bằng nhau — đó là zero-cost, try/catch không tốn gì ở đường thường. Giờ đổi hàm để nó throw một ngoại lệ mỗi lần gọi, bắt trong catch, và đo (dùng số vòng nhỏ hơn nhiều, vì nó sẽ chậm): mỗi lần throw tốn hàng trăm nano-giây tới micro-giây — gấp hàng nghìn lần một vòng lặp thường. Cuối cùng, thử phiên bản trả mã lỗi và kiểm bằng một if mỗi lần: ở happy path nó có thể chậm hơn bản try/catch, vì phải kiểm nhánh mỗi lời gọi. Ba mươi giây đó cho bạn thấy điều mà cả "try/catch làm chậm" lẫn "exception rẻ" đều giấu đi: ngoại lệ đẩy toàn bộ chi phí sang đường ném hiếm gặp — miễn phí khi không ném, rất đắt khi ném — nên hãy dùng nó cho lỗi hiếm, và trả mã lỗi cho những chuyện thường xuyên.