Ngoại lệ là một trong những chủ đề gây cãi nhau to nhất trong C++. Một phe khăng khăng "try/catch làm chậm chương trình, tránh xa exception ra". Phe kia dùng throw như một câu lệnh goto sang chảnh, ném cho mọi tình huống. Đo lại thì thấy cả hai đều hiểu sai chỗ mà ngoại lệ thật sự tốn tiền.

Hãy nghĩ về lối thoát hiểm trong một tòa nhà. Ngày thường bạn đi lại, làm việc bình thường, chẳng ai bắt bạn trả phí gì cho mấy cái cửa thoát hiểm với biển chỉ dẫn — chúng chỉ nằm đó. Nhưng khi thật sự có cháy, cả một quy trình đắt đỏ được kích hoạt: còi hú, mọi người bỏ hết việc, sơ tán theo lối đã định, kiểm đếm từng người. Trình biên dịch C++ hiện đại làm ngoại lệ đúng theo kiểu đó, gọi là zero-cost. Đường không ném — cái ta hay gọi là happy path — không phải trả một lệnh thừa nào cho việc có 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 tới khi thật sự throw. Còn khi ném, cái giá dồn lại rất lớn: CPU phải gỡ ngược ngăn xếp, tra bảng tìm chỗ xử lý, chạy destructor của mọi đối tượng dọc đường, khớp kiểu với các mệnh đề catch. Tôi đo cả hai đầu.

Con số

Chạy một hàm nhỏ hàng trăm triệu lần, lấy min sau warmup:

Cách viết ns mỗi lần
Đường thường, không try/catch 0,675
Đường thường, có try/catch (không ném) 0,676 — tức 1,00x, zero-cost thật
Đường thường, dùng mã lỗi (kiểm nhánh mỗi lần) 0,930 — chậm hơn cả try/catch
Một lần throw + catch thật ~800 ns — gấp 1184 lần

Điều thứ nhất: bọc try/catch quanh code mà không ném thì tốn đúng bằng không. 0,676 so với 0,675, coi như y hệt. Cái định kiến "try/catch làm chậm" đơn giản là sai với trình biên dịch ngày nay.

Điều thứ hai làm nhiều người bất ngờ: cách "an toàn" mà dân sợ-exception hay dùng — trả mã lỗi rồi kiểm sau mỗi lời gọi — lại chậm hơn cả try/catch, 0,930 so với 0,676. Vì nó bắt mọi lời gọi phải kiểm một cái nhánh "có lỗi không", dù lỗi gần như chẳng bao giờ tới. Exception thì đẩy sạch chi phí sang đường ném; happy path không phải gánh gì. Với lỗi hiếm, exception cho đường thường nhanh hơn mã lỗi.

Nhưng điều thứ ba mới là vế còn lại: một cú throw thật tốn khoảng 800 nano-giây, tức gần một micro-giây, gấp 1184 lần một vòng lặp thường. Cả cái quy trình sơ tán kia dồn vào một lần ném. Ném một phát cho lỗi hiếm thì chẳng sao. Nhưng ném trong vòng lặp nóng, hay cho một tình huống xảy ra liên tục, là bạn trả cái giá 800 ns đó mỗi lần — thảm họa.

Hai định kiến, cả hai cùng trật

Tôi bước vào với định kiến bi quan quen thuộc: cứ có try/catch là chậm, nên né exception, xài mã lỗi cho lành. Đo xong thì thấy happy path có try/catch bằng đúng không có (zero-cost), mà cái "giải pháp thay thế" bằng mã lỗi lại còn chậm hơn vì bắt mọi lời gọi kiểm nhánh. Định kiến này khiến tôi vừa né exception vô ích, vừa chọn cách chậm hơn.

Định kiến ngược lại cũng sai không kém: "exception zero-cost mà, cứ throw thoải mái cho mọi thứ". Cái "zero-cost" chỉ nói về đường không ném. Ném thật thì đắt kinh khủng. Dùng exception để báo "không tìm thấy" trong một vòng tra cứu nóng, hay để nhảy ra khỏi vòng lặp, là biến mỗi lần thành một cú unwind micro-giây, chậm gấp bội một cái if hay return.

Ngoài đời thì sao

Quy tắc gọn nằm ở tần suất. Lỗi hiếm và thật sự bất thường — file không mở được, kết nối rớt, dữ liệu hỏng — thì dùng exception là lý tưởng: happy path zero-cost, code sạch, khỏi rải kiểm mã lỗi khắp nơi. Còn những chuyện xảy ra thường xuyên — không tìm thấy phần tử, hết dữ liệu, input người dùng sai định dạng — thì trả về giá trị hay mã lỗi (std::optional, std::expected) và kiểm bằng một nhánh, vì một throw tốn 800 ns còn một nhánh đoán đúng gần như miễn phí. Ném cho cái hiếm, return cho cái thường.

Chuyện này rất giống ở các ngôn ngữ khác, và mỗi cộng đồng chọn một triết lý. Java dùng exception, nhưng phần đắt nhất của nó là lúc fillInStackTrace chụp lại toàn bộ ngăn xếp khi tạo exception — nên dùng exception cho luồng điều khiển bình thường trong Java là một antipattern nổi tiếng, và người ta có mẹo override để bỏ stack trace khi cần ném nhiều. Go thì đi hẳn hướng ngược lại: lỗi là giá trị trả về tường minh, rẻ và kiểm bằng if err != nil, còn panic được để dành cho những gì thật sự không thể phục hồi — đúng tinh thần "throw chỉ cho cái hiếm". Cùng một bài học đo được ở C++, chỉ khác cách mỗi ngôn ngữ đóng gói.

Muốn tự thấy thì viết một hàm nhỏ, gọi vài trăm triệu lần, đo. Bọc nó trong try/catch mà không ném rồi đo lại: gần như bằng nhau. Đổi hàm để nó throw mỗi lần gọi (nhớ giảm số vòng vì sẽ chậm lắm): mỗi cú ném tốn hàng trăm nano-giây tới cả micro-giây. Cuối cùng thử bản trả mã lỗi kiểm bằng if: ở đường thường nó có khi còn chậm hơn try/catch. 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ành nó cho lỗi hiếm, và trả mã lỗi cho những chuyện thường ngày.