Hàm của bạn có màu gì?

Phần mềm26 tháng 5, 2026·12 phút đọc

Bài viết phân tích vấn đề "hàm có màu" trong lập trình, nơi sự phân chia giữa hàm đồng bộ và bất đồng bộ làm phức tạp hóa kiến trúc phần mềm. Tác giả so sánh cách tiếp cận async/await của JavaScript hay C# với cơ chế luồng (threads/goroutines) của Go để tìm ra giải pháp tối ưu cho xử lý bất đồng bộ.

Hàm của bạn có màu gì?

Hàm của bạn có màu gì?

Không biết bạn thế nào, nhưng không gì khiến tôi hào hứng vào buổi sáng hơn là một bài phàn nàn về ngôn ngữ lập trình theo kiểu cũ. Thật kích động khi thấy ai đó châm biếm một trong những ngôn ngữ "blub" mà đám đông sử dụng, vật lộn với nó trong ngày giữa những lần lén lút truy cập StackOverflow.

(Trong khi đó, bạn và tôi, chỉ sử dụng những ngôn ngữ tiến bộ nhất. Những công cụ sắc bén được thiết kế cho đôi bàn tay lành nghề của những chuyên gia như chúng ta.)

Tất nhiên, với tư cách là tác giả của bài viết này, tôi gặp rủi ro. Ngôn ngữ mà tôi chế giễu có thể là ngôn ngữ bạn thích! Vô tình, tôi có thể đã để đám đông xâm nhập vào blog của mình, mang theo nĩa và đuốc, và tờ rơi liều lĩnh này có thể khơi dậy sự giận dữ của họ!

Để bảo vệ bản thân khỏi sức nóng của những ngọn lửa đó, và để tránh xúc phạm cảm xúc có thể mong manh của bạn, thay vào đó, tôi sẽ phàn nàn về một ngôn ngữ do tôi mới bịa ra. Một kẻ rơm có mục đích duy nhất là bị đốt cháy.

Tôi biết, điều này có vẻ vô lý đúng không? Tin tôi đi, đến cuối bài, chúng ta sẽ thấy khuôn mặt (hoặc những khuôn mặt!) của ai được vẽ trên cái đầu rơm đó.

Một ngôn ngữ mới

Học một ngôn ngữ mới (tệ hại) hoàn toàn chỉ cho một bài đăng blog là một việc lớn, vì vậy hãy nói rằng nó chủ yếu giống với một ngôn ngữ mà bạn và tôi đã biết. Chúng ta sẽ nói rằng nó có cú pháp kiểu như JS. Dấu ngoặc nhọn và dấu chấm phẩy. if, while, v.v. Tiếng chung của các lập trình viên.

Tôi chọn JS không phải vì bài viết này nói về nó. Chỉ đơn giản là nó là ngôn ngữ mà bạn, đại diện thống kê của độc giả trung bình, có khả năng hiểu nhất. Voilà:

function thisIsAFunction() {
  return "It's awesome";
}

Vì kẻ rơm của chúng ta là một ngôn ngữ hiện đại (tệ), chúng ta cũng có các hàm hạng nhất (first-class functions). Vì vậy bạn có thể tạo ra một cái gì đó như thế này:

// Trả về một danh sách chứa tất cả các phần tử trong collection
// khớp với predicate.
function filter(collection, predicate) {
  var result = [];
  for (var i = 0; i < collection.length; i++) {
    if (predicate(collection[i])) {
      result.push(collection[i]);
    }
  }
  return result;
}

Bạn có thể truyền bất kỳ hàm nào bạn thích vào predicate. Nó hoạt động rất tốt.

Bài toán về màu sắc

Nhưng sau đó, ngôn ngữ này thêm vào một tính năng mới: bất đồng bộ (asynchrony). Giả sử chúng ta thêm một cách để tải một tệp từ đĩa. Nó trông như thế này:

function readFile(filename) {
  // Đọc tệp từ đĩa và trả về nội dung.
}

Nhưng đợi đã, đọc đĩa rất chậm. Nếu chúng ta đọc nó đồng bộ, chúng ta sẽ chặn toàn bộ chương trình trong khi đĩa quay. Đó là điều tối kỵ trong một ngôn ngữ hiện đại (tệ). Vì vậy, chúng ta làm cho nó bất đồng bộ. Bây giờ nó trông như thế này:

function readFile(filename, callback) {
  // Bắt đầu đọc tệp. Khi hoàn thành, gọi callback.
}

Vấn đề ở đây là gì? Hãy nhìn lại hàm filter. Nó mong đợi predicate là một hàm nhận một giá trị và trả về true hoặc false ngay lập tức. Nhưng nếu predicate của bạn cần đọc một tệp để quyết định xem phần tử có khớp hay không thì sao? Nó không thể trả về ngay lập tức. Nó phải bất đồng bộ.

Điều này có nghĩa là chúng ta không thể sử dụng filter thông thường với một predicate bất đồng bộ. Chúng ta cần một phiên bản bất đồng bộ của filter.

Đây là những gì tôi gọi là Hàm có màu.

Nếu một hàm là đồng bộ, nó là màu xanh. Nếu nó là bất đồng bộ, nó là màu đỏ.

Quy tắc của ngôn ngữ này rất đơn giản:

  1. Nếu một hàm là màu xanh, nó chỉ có thể gọi các hàm màu xanh.
  2. Nếu một hàm là màu đỏ, nó chỉ có thể gọi các hàm màu đỏ.

Điều này có vẻ vô hại cho đến khi bạn nhận ra rằng nó áp dụng cho toàn bộ cơ sở mã của bạn. Nếu bạn gọi một hàm màu đỏ, hàm gọi của bạn trở thành màu đỏ. Màu sắc lan truyền như một virus qua hệ thống gọi hàm của bạn.

Điều này dẫn đến năm quy tắc đau khổ:

  1. Các hàm màu xanh dễ dàng để viết và dễ lý trí.
  2. Các hàm màu đỏ khó viết và khó lý trí.
  3. Nếu bạn gọi một hàm màu đỏ từ một hàm màu xanh, bạn đã làm cho hàm màu xanh đó trở nên bất đồng bộ. Nó bây giờ là màu đỏ.
  4. Không có cách nào để gọi một hàm màu xanh từ một hàm màu đỏ và giữ cho nó màu xanh.
  5. Bạn có thể viết một thư viện mã màu xanh, nhưng bạn không thể sử dụng nó từ mã màu đỏ mà không trả giá.

Async/Await không giải quyết được vấn đề

Nhiều ngôn ngữ hiện đại đã cố gắng giải quyết vấn đề này bằng cách thêm các từ khóa asyncawait. JavaScript, C#, Python, Dart... tất cả đều có chúng.

Ý tưởng là bạn có thể viết mã trông giống như mã đồng bộ, nhưng thực sự là bất đồng bộ.

async function readAndProcess() {
  const data = await readFile('data.txt');
  return processData(data);
}

Nhưng hãy nhìn kỹ hơn. Hàm readAndProcess được đánh dấu là async. Điều này có nghĩa là nó trả về một Promise (hoặc Future), không phải giá trị thực tế. Nó là một hàm màu đỏ.

Nếu bạn muốn gọi nó, bạn phải await nó. Điều này có nghĩa là hàm gọi nó cũng phải là async. Và cứ như vậy.

async/await làm cho cú pháp dễ chịu hơn, nhưng nó không xóa bỏ sự phân chia màu sắc. Bạn vẫn bị mắc kẹt trong thế giới của các hàm màu đỏ không thể gọi các hàm màu xanh một cách miễn phí.

Ngôn ngữ nào không bị "màu"?

Vậy JS, Dart, C# và Python đều có vấn đề này. CoffeeScript và hầu hết các ngôn ngữ khác biên dịch sang JS cũng vậy (đó là lý do Dart kế thừa nó).

Bạn có muốn biết một ngôn ngữ không có vấn đề này không? Java. Tôi biết đúng không? Bao nhiêu lần bạn được nói: "À, Java là thứ thực sự làm đúng điều này"? Nhưng đó là sự thật. Trong phòng vệ của họ, họ đang tích cực cố gắng sửa sai lầm này bằng cách chuyển sang futures và async IO. Nó giống như một cuộc đua xuống đáy.

C# thực sự cũng có thể tránh được vấn đề này. Họ đã chọn việc có màu. Trước khi họ thêm async-await và tất cả những thứ Task, bạn chỉ sử dụng các cuộc gọi API đồng bộ thông thường.

Ba ngôn ngữ nữa không có vấn đề này: Go, Lua và Ruby.

Bạn có đoán được điểm chung của chúng không?

Luồng (Threads). Hoặc chính xác hơn là: nhiều ngăn xếp gọi (callstack) độc lập có thể chuyển đổi giữa nhau. Không nhất thiết chúng phải là luồng hệ điều hành. Goroutines trong Go, coroutines trong Lua và fibers trong Ruby là hoàn toàn phù hợp.

Ngăn xếp gọi được hiện thực hóa (Reified callstacks)

Vấn đề cơ bản là "Làm thế nào để bạn tiếp tục nơi bạn đã dừng lại khi một thao tác hoàn thành"? Bạn đã xây dựng một callstack lớn và sau đó bạn gọi một số thao tác IO. Để có hiệu suất, thao tác đó sử dụng API bất đồng bộ cơ bản của hệ điều hành. Bạn không thể đợi nó hoàn thành vì nó sẽ không hoàn thành ngay lập tức. Bạn phải trả về tất cả cách trở lại vòng lặp sự kiện (event loop) của ngôn ngữ của mình và cho hệ điều hành một chút thời gian để quay trước khi nó xong.

Khi thao tác hoàn thành, bạn cần tiếp tục những gì bạn đang làm. Cách thông thường mà một ngôn ngữ "ghi nhớ nơi nó đang ở" là callstack. Nó theo dõi tất cả các hàm hiện đang được gọi và con trỏ lệnh nằm ở đâu trong mỗi hàm.

Nhưng để thực hiện async IO, bạn phải giải nén và loại bỏ toàn bộ callstack C. Một kiểu Catch-22. Bạn có thể làm IO siêu nhanh, bạn chỉ không thể làm bất cứ điều gì với kết quả! Mọi ngôn ngữ có async IO trong lõi của nó — hoặc trong trường hợp của JS, vòng lặp sự kiện của trình duyệt — đều đối phó với điều này theo một cách nào đó.

Node với các callback của nó nhét tất cả các khung gọi đó vào các closures. Khi bạn làm:

function makeSundae(callback) {
  scoopIceCream(function (iceCream) {
    warmUpCaramel(function (caramel) {
      callback(pourOnIceCream(iceCream, caramel));
    });
  });
}

Mỗi biểu thức hàm đó đóng (closes over) trên tất cả ngữ cảnh xung quanh của nó. Nó di chuyển các tham số như iceCreamcaramel khỏi callstack và vào heap. Khi hàm bên ngoài trả về và callstack bị hủy, không sao cả. Dữ liệu đó vẫn đang lơ lửng quanh heap.

Vấn đề là bạn phải hiện thực hóa thủ công từng bước một trong những bước này. Thực ra có một tên cho sự chuyển đổi này: continuation-passing style (CPS). Nó được phát minh bởi những tin tặc ngôn ngữ vào những năm 70 như một biểu diễn trung gian để sử dụng trong nội bộ của trình biên dịch của họ. Đó là một cách kỳ quặc để đại diện cho mã tình cờ làm cho một số tối ưu hóa trình biên dịch dễ thực hiện hơn.

Không ai từng nghĩ trong một giây rằng một lập trình viên sẽ viết mã thực tế như vậy. Và sau đó Node xuất hiện và đột nhiên chúng ta ở đây giả vờ là các backend của trình biên dịch. Chúng ta đã sai ở đâu?

Một giải pháp được tạo ra

Async-await thực sự giúp ích. Nếu bạn lột bỏ hộp sọ của trình biên dịch và xem nó đang làm gì khi nó gặp một cuộc gọi await, bạn sẽ thấy nó thực sự đang thực hiện chuyển đổi CPS. Đó là lý do tại sao bạn cần sử dụng await trong C#: nó là một manh mối cho trình biên dịch để nói, "phá vỡ hàm ở đây". Mọi thứ sau await được nâng lên một hàm mới mà trình biên dịch tổng hợp thay cho bạn.

Đây là lý do async-await không cần bất kỳ hỗ trợ thời gian chạy nào trong .NET framework. Trình biên dịch biên dịch nó thành một chuỗi các closures được xích lại với nhau mà nó đã có thể xử lý.

Bạn có thể đang tự hỏi khi nào tôi sẽ đề cập đến generators. Ngôn ngữ của bạn có từ khóa yield không? Sau đó nó có thể làm điều gì đó rất giống nhau.

Vì vậy, với callbacks, promises, async-await và generators, cuối cùng bạn kết thúc bằng cách lấy hàm bất đồng bộ của mình và lan tỏa nó ra một loạt các closures sống trên heap.

Hàm của bạn chuyển cái ngoài cùng vào thời gian chạy. Khi vòng lặp sự kiện hoặc thao tác IO hoàn thành, nó gọi hàm đó và bạn tiếp tục nơi bạn đã dừng lại. Nhưng điều đó có nghĩa là mọi thứ ở trên bạn cũng phải trả về. Bạn vẫn phải giải nén toàn bộ ngăn xếp.

Đây là nơi quy tắc "hàm màu đỏ chỉ có thể được gọi bởi các hàm màu đỏ" xuất hiện. Bạn phải đóng gói toàn bộ callstack tất cả cách trở lại main() hoặc trình xử lý sự kiện.

Giải pháp của Go

Nhưng nếu bạn có luồng (màu xanh hoặc cấp hệ điều hành), bạn không cần làm điều đó. Bạn chỉ cần tạm dừng toàn bộ luồng và nhảy thẳng trở lại hệ điều hành hoặc vòng lặp sự kiện mà không phải trả về từ tất cả các hàm đó.

Go là ngôn ngữ làm điều này đẹp nhất theo ý kiến của tôi. Ngay khi bạn thực hiện bất kỳ thao tác IO nào, nó chỉ cần đỗ (park) goroutine đó và tiếp tục bất kỳ cái nào khác không bị chặn bởi IO.

Nếu bạn nhìn vào các thao tác IO trong thư viện chuẩn, chúng có vẻ đồng bộ. Nói cách khác, chúng chỉ làm việc và sau đó trả về kết quả khi chúng xong. Nhưng không phải là chúng đồng bộ theo nghĩa mà nó sẽ có nghĩa trong JavaScript. Mã Go khác có thể chạy trong khi một trong các thao tác này đang chờ xử lý. Đó là Go đã loại bỏ sự phân biệt giữa mã đồng bộ và bất đồng bộ.

Đồng thời trong Go là một khía cạnh của cách bạn chọn mô hình hóa chương trình của mình, và không phải là một màu sắc bị thiêu đốt vào mỗi hàm trong thư viện chuẩn. Điều này có nghĩa là tất cả nỗi đau của năm quy tắc tôi đã đề cập ở trên hoàn toàn và triệt để bị loại bỏ.

Vì vậy, lần tới khi bạn bắt đầu kể cho tôi nghe về một số ngôn ngữ mới nóng hổi và câu chuyện đồng thời (concurrency) của nó tuyệt vời như thế nào vì nó có các API bất đồng bộ, bây giờ bạn sẽ biết tại sao tôi bắt đầu nghiến răng. Bởi vì điều đó có nghĩa là bạn quay lại với các hàm màu đỏ và màu xanh.

Chia sẻ:FacebookX
Nội dung tổng hợp bằng AI, mang tính tham khảo. Xem bài gốc ↗