BEAM là gì: Máy ảo Erlang, tiến trình nhẹ và cây giám sát OTP

BEAM là máy ảo thực thi của hệ sinh thái Erlang và Elixir, nơi quyết định mọi thứ về cách một hệ thống chịu tải cao được xây dựng: tiến trình nhẹ, truyền tin nhắn, chia sẻ bộ nhớ theo bản sao khi cần, và cây giám sát tự động khởi động lại. Bài viết này giải thích cơ chế bên trong BEAM, lý do ngôn ngữ này xử lý hàng triệu tác vụ đồng thời, và các khái niệm OTP mà lập trình viên Elixir cần nắm.

Khác biệt cốt lõi so với hầu hết ngôn ngữ phổ biến nằm ở chỗ BEAM không dùng thread của hệ điều hành cho đơn vị thực thi song song. Nó dùng tiến trình riêng, gọi là process, là các luồng thực thi màu xanh chạy ngay trong bộ nhớ của máy ảo.

BEAM là gì

BEAM là lớp thực thi của hệ thống OTP, bao gồm máy ảo và toàn bộ thư viện ứng dụng kèm theo. Bên trong nó, ERTS (Erlang Runtime System) đảm nhiệm quản lý bộ nhớ, bộ lập lịch, nạp mã nguồn và xử lý tín hiệu, theo mô tả trong tài liệu kiến trúc hệ thống Erlang.

Thư viện Process của Elixir chỉ cung cấp các tiện ích mức thấp. Tài liệu chính thức khuyến nghị dùng các lớp trừu tượng cao hơn như GenServer, Agent, Task, Registry và Supervisor cho code thực tế.

Tiến trình nhẹ không phải thread của hệ điều hành

Đây là hiểu lầm phổ biến nhất về Erlang. Theo tài liệu quy trình của Erlang, các process của Erlang được thiết kế cho đồng thời quy mô lớn: chúng nhẹ, có thể lớn lên và co lại động, chi phí tạo và huỷ thấp, và chi phí lập lịch nhỏ.

Mỗi process có heap riêng và bộ thu gom rác riêng, nên việc thu gom rác trong một process không ảnh hưởng tới process khác. Dữ liệu được truyền giữa các process dưới dạng bản sao, vì vậy không có trạng thái chia sẻ nào cần phân đồng thời và không tồn tại race condition kiểu truy cập bộ nhớ cùng lúc.

Sơ đồ and-split: một token được nhân đôi thành hai luồng thực thi song song

Để cho thấy quy mô, tài liệu phát hành của OTP 26 đã nâng trần mặc định của số process lên 1.048.576 (một triệu không trăm nghìn bảy trăm tám mươi sáu nghìn). Đây không phải con số chỉ mang tính lý thuyết, mà là mức mà runtime thiết kế sẵn để chịu được.

Trong bản ghi: hộp thư và truyền tin nhắm

Toàn bộ giao tiếp giữa các process đi qua tín hiệu bất đồng bộ. Tin nhắn được gửi bằng toán tử ! và nhận bằng receive. Mỗi process có một hộp thư riêng, và tin nhắn mới được thêm vào cuối hàng đợi.

Điểm hay là BEAM hỗ trợ selective receive: process có thể chờ một mẫu tin cụ thể, thay vì bắt buộc lấy đầu hàng đợi. Điều này biến mô hình “hộp thư” thành “hộp thư có địa chỉ”.

Elixir bổ sung thêm khái niệm process alias (từ OTP 24). Alias tạo ra một địa chỉ tạm thời, và tin nhắn gửi tới alias đã bị vô hiệu hoá sẽ bị loại bỏ trước cả khi vào hàng đợi. Đây là giải pháp gọn cho bài toán request/response: nếu một reply đến trễ sau khi request đã hết hạn, nó bị bỏ qua ngay thay vì làm nhiễu state của process.

Cũng trong tinh thần đó, Erlang có hệ thống process alias mà tài liệu ghi rõ đây là quyết định thiết kế dựa trên hiệu năng và khả năng mở rộng, đồng thời giữ tính trong suốt của phân tán.

Bàn tổng đài điện thoại cổ với tai nghe và dây nối, ẩn dụ cho truyền tin nhắm

Preemption bằng đếm reduction

Đây là chi tiết kỹ thuật hay nhất của BEAM. Tài liệu ERTS mô tả rõ: việc lập lịch là preemptive, và bất kể mức ưu tiên, một process sẽ bị thu hồi quyền chạy khi nó tiêu thụ vượt quá một số reduction nhất định kể từ lần được chọn chạy gần nhất.

Cơ chế này giải quyết vấn đề kinh điển của hệ đồng thời: một process chạy vòng lặp tính toán nặng có thể chiếm CPU và làm nghẽn mọi process khác. Với Erlang, điều này không xảy ra vì hệ thống không dựa vào cơ chế ngắt định kỳ của hệ điều hành, mà đếm số thao tác thực thi.

Trần một lần chạy là 4.000 reduction kể từ OTP 19.2. Ngoài ra, còn có lệnh erlang:bump_reductions/1 để tăng bộ đếm thủ công khi bạn biết đoạn code sắp tới sẽ tốn nhiều thời gian. OTP 29 còn tinh chỉnh để phép toán số nguyên lớn cũng tăng bộ đếm reduction, khiến việc chuyển ngữ cảnh diễn ra thường xuyên hơn ở đúng những chỗ cần thiết.

Bộ lập lịch và các hàng đợi

BEAM phân biệt ba loại bộ lập lịch: bộ lập lịch thường chạy code của process, dirty CPU scheduler dành cho tác vụ CPU-intensive, và dirty IO scheduler cho tác vụ I/O kéo dài. Công việc chỉ di chuyển giữa các bộ lập lịch cùng loại, không bao giờ nhảy giữa các loại khác nhau.

Mỗi loại có hàng đợi riêng, và từ ERTS 9.0 runtime mặc định có nhiều bộ lập lịch hơn số logical processor, nhờ cơ chế dirty scheduler. Số bộ lập lịch online có thể điều chỉnh động bằng system_flag(schedulers_online, N), và tỉ lệ dirty scheduler online tự động thay đổi theo tỉ lệ đó.

Đáng chú ý, Erlang mặc định không gán bộ lập lịch vào logical processor cụ thể, và đây là khuyến nghị chính thức để tránh suy giảm hiệu năng. Thay vào đó, việc phân bổ được quản lý động, từ ERTS 10 trở đi còn bổ sung tối ưu cho việc các bộ lập lịch tự lấy việc của nhau (task stealing).

Mức ưu tiên process có bốn mức, từ low đến max. Erlang không có khái niệm priority inheritance hay priority ceiling, nên nếu bạn dùng nhiều mức ưu tiên, bạn phải tự xử lý nguy cơ đảo ngược thứ tự ưu tiên.

Cây giám sát và chiến lược khởi động lại

Đây là phần thực tế nhất của OTP. Theo tài liệu nguyên tắc supervisor, supervisor là process theo dõi worker, và khi worker gặp sự cố, supervisor khởi động lại nó. Cây phân cấp supervisor và worker là nền tảng để xây dựng phần mềm chịu lỗi.

OTP định nghĩa bốn chiến lược khởi động lại:

  • one_for_one: chỉ khởi động lại child vừa chết. Đây là mặc định.
  • one_for_all: dừng và khởi động lại toàn bộ child.
  • rest_for_one: dừng và khởi động lại các child đứng sau child vừa chết, theo đúng thứ tự khởi động.
  • simple_one_for_one: dành cho child đồng nhất.

Child khởi động đúng thứ tự trong danh sách và kết thúc theo thứ tự ngược lại, đảm bảo phụ thuộc giữa các thành phần được tôn trọng.

Maximum Restart Intensity

Điểm tinh tế nhất nằm ở tham số intensity và period. Nếu số lần khởi động lại vượt quá MaxR lần trong MaxT giây, supervisor sẽ dừng tất cả child rồi tự kết thúc với lý do shutdown, để cấp trên xử lý tiếp. Mặc định là intensity => 1, period => 5.

Cơ chế này biến một lỗi nghiêm trọng tiềm ẩn thành sự kiện được xử lý có kiểm soát, thay vì để hệ thống lặp vòng khởi động lại vô tận và che giấu nguyên nhân gốc. Tổng số lần khởi động lại tối đa của cả cây là tích của intensity các tầng, nên tài liệu khuyến nghị giới hạn ở khoảng 3 cho supervisor cấp cao nhất.

auto_shutdown

Tính năng này từ OTP 24 cho phép supervisor tự dừng khi tất cả child đáng kể đã kết thúc. Tài liệu cảnh báo không nên bật auto_shutdown cho supervisor cấp cao nhất của một application, và supervisor bật tính năng này không nên là permanent child, nếu không sẽ bị khởi động lại vô hạn và làm cạn hạn mức intensity của supervisor cha.

Behaviour: tách phần generic và phần specific

OTP chuẩn hoá bốn behaviour: gen_server cho mô hình client-server, gen_statem cho máy trạng thái, gen_event cho xử lý sự kiện, và supervisor cho node trong cây giám sát.

Sơ đồ máy trạng thái hữu hạn với các trạng thái và các chuyển tiếp có nhãn

Cơ chế behaviour tách code thành phần generic (module behaviour có sẵn trong OTP, chứa logic khởi động, lập lịch, xử lý timeout) và phần specific (module callback do bạn viết, chỉ chứa nghiệp vụ). Trình biên dịch hiểu thuộc tính -behaviour(B) và cảnh báo khi bạn thiếu hàm callback bắt buộc.

Lợi ích thực tế rất rõ: tên server và định dạng tin nhắm trở thành chi tiết triển khai, ẩn khỏi phía client. Khi bạn đổi tên server hay đổi cấu trúc tin nhắm nội bộ, code phía client không phải sửa theo. Đổi lại, bạn đánh đổi một phần hiệu năng để đổi lấy tính tổng quát, đúng như tài liệu thừa nhận.

Quy trình thực tế khi xây dựng hệ thống

Hãy bắt đầu từ cấu trúc cây giám sát trước khi viết logic nghiệp vụ. Xác định các process con cần có, quan hệ phụ thuộc giữa chúng, rồi chọn chiến lược khởi động lại phù hợp với từng nhóm. Đặt intensity đủ thấp để lỗi thật không bị che giấu.

Đối với xử lý song song, dùng Task và Task.Supervisor.async_stream/3 thay vì tự quản lý việc spawn và thu thập kết quả. Với trạng thái cần chia sẻ giữa nhiều process, Agent cho trạng thái đơn giản, còn GenServer khi cần tuần tự hoá truy cập và có logic nghiệp vụ phức tạp.

Đừng dùng Process trực tiếp trong code ứng dụng nếu đã có abstraction phù hợp, vì bạn sẽ tự xử lý các chi tiết về lập lịch và giám sát mà OTP đã giải quyết sẵn.

Kết luận

BEAM đứng vững nhờ ba quyết định thiết kế kết hợp: tiến trình cô lập với bộ nhớ riêng, truyền tin nhắm thay cho chia sẻ trạng thái, và cây giám sát biến lỗi thành sự kiện bình thường có thể xử lý. Hệ quả là bạn có thể viết code xử lý hàng trăm nghìn tác vụ đồng thời mà không cần quản lý thread hay khoá (lock), và khi một thành phần hỏng, hệ thống tự phục hồi thay vì sập xuống toàn bộ.

Nếu bạn muốn bắt đầu, hãy cài Elixir, mở iex và tự tạo vài chục process gửi tin nhắn qua lại. Cảm giác về chi phí thấp của việc tạo process sẽ giúp bạn hiểu vì sao mô hình này khác biệt. Yêu cầu phiên bản hiện tại là Elixir 1.18 trở lên cùng Erlang/OTP 27 trở lên.

Nguồn tham khảo: Erlang Design Principles | Erlang Processes Reference Manual | Erlang Supervisor Principles | Elixir Introduction | Elixir Process Module

Tôi là một lập trình viên IOS. Code chính là IOS nhưng thỉnnh thoảng vẫn đá sang Android hoặc web. Mặc dù không quá thông thạo nhưng tôi sẽ chia sẻ những kiến thức mà mình đã tìm hiểu, áp dụng qua.

Bài viết liên quan

Thuật toán Dijkstra: Tìm đường ngắn nhất trên đồ thị có trọng số

Thuật toán Dijkstra là thuật toán tìm đường ngắn nhất trên đồ thị có trọng số, được Edsger W. Dijkstra đề xuất vào năm và phổ biến rộng rãi nhờ…

Xem thêm

Circuit Breaker Pattern: Chống lỗi dây chuyền trong hệ thống

Circuit Breaker Pattern là một mẫu thiết kế lập trình giúp hệ thống chịu lỗi tốt hơn bằng cách ngắt kết nối tới dịch vụ đang hỏng, thay vì cứ…

Xem thêm

Biểu thức chính quy là gì? Từ regex đến máy trạng thái NFA

Biểu thức chính quy (tên tiếng Anh là regular expression, thường gọi tắt là regex) là một mẫu văn bản dùng để mô tả một họ những chuỗi ký tự…

Xem thêm
0 0 đánh giá
Article Rating
Theo dõi
Thông báo của
guest
0 Comments
Cũ nhất
Mới nhất Được bỏ phiếu nhiều nhất