Nhật ký hốt sh*t—Chuyện về cái service A
Bài viết mà thằng chả chém gió về cách chả monitoring và debug một sự cố gặp phải khi vận hành hệ thống Elixir
Nếu bạn click vào đọc bài viết này với ước mơ được trường sinh bất lão, bạn có thể mở tab mới và google từ khóa “Hội Thánh Đức Chúa Trời”. Còn không thì tui mong sau khi đọc bài viết, bạn sẽ học Elixir.
Bài viết khá dài, nhưng túm cái váy lại là bao gồm ba phần:
Nhưng trước hết hãy cùng tui đọc qua bài toán nổi tiếng: Bài toán điểm danh của lớp 1A.
Lớp 1A có 50 bé học sinh. Cứ mỗi đầu buổi học, lớp đều điểm danh bằng cách cho lần lượt từng bé sẽ ghi tên vào một cuốn tập theo thứ tự ABC.
Bài toán điểm danh của lớp 1A có thể được biểu diễn bằng đoạn mã giả như sau:
class Student
def initialize(name)
@name = name
end
def roll_call(notebook)
notebook.write(@name)
end
end
class ClassRoom
def initialize(students, notebook)
@students = students
@notebook = notebook
end
def start()
@students.each do |student|
student.roll_call(@notebook)
end
end
end
Sẽ không có gì phải lăn tăn nếu học sinh cứ ghi vào cuốn tập tuần tự. Nhưng trong thực tế thì không phải học sinh nào cũng đến lớp đúng giờ, ta không thể vì bé Quần Cam tới trễ 30 phút mà bắt tất cả học sinh phía sau cũng phải chờ 30 phút mới được điểm danh. Cho nên ta phải hỗ trợ điểm danh bất đồng bộ: ai tới trước điểm danh trước, tới sau điểm danh sau.
Và một cái rẹt, bạn đã văng khỏi thế giới tuần tự để đến với thế giới concurrency!
Khi nói đến concurrency, ta nói về khả năng các phần khác nhau trong ứng dụng có thể được thực thi không theo một thứ tự nào cả (out-of-order), mà vẫn đảm bảo kết quả cuối cùng.
Với bài toán điểm danh bất đồng bộ ở trên, ta có thể viết lại ứng dụng với một đoạn mã giả như bên dưới, bằng cách quăng thao tác điểm danh vào một thread, như khi không người ta vẫn dạy lập trình đa luồng (multithreaded programming) ở trường. Khi một học sinh chưa đến lớp (biến @is_here
chưa được bật), thread của bé ấy sẽ ngủ 10 giây, nhả lại quyền thực thi cho thread khác.
class Student
def initialize(name, is_here)
@name = name
@is_here = is_here
end
def roll_call(notebook)
if @is_here
notebook.write(@name)
true
else
sleep(10)
@is_here = true
roll_call(notebook)
end
end
end
class ClassRoom
def start()
@students.each do |student|
Thread.new do
student.roll_call(@notebook)
end
end
end
end
Bây giờ hãy tưởng tượng với đoạn code trên, bé nào trong lớp 1A cũng có thể ghi vào cuốn tập bất kì lúc nào. Mặc dù 50 học sinh chụm đầu để ghi vào cuốn tập thì hơi phi lý thật, nhưng hãy ngưng khó tính và chấp nhận đi. Sau một thời gian bạn sẽ thấy cuốn tập điểm danh trở nên rối loạn như 12 sứ quân.
Quầ
Quầnn Cam Quần Đùi
Chíp
Quần Sọt
Vì sao lại có tình trạng như vậy? Bởi vì Quần Cam, Quần Chíp và Quần Đùi ghi vào cuốn tập tại cùng một thời điểm. Khi Quần Chíp vừa viết được chữ “Quầ” thì tới lượt Quần Cam chiếm được quyền ghi, thành thử ra nội dụng cuốn tập trở nên lộn xộn không ra cái thể thống gì cả.
Ta rút ra được một bài học: concurrency phải có control.
Để giải quyết vấn đề này, cô giáo đưa ra một luật: chỉ một bé được viết vào cuốn tập trong cùng một thời điểm. Trong multithreaded programming, luật đó có thể được hiện thực bằng lock/mutex:
class ClassRoom
def start()
notebook_semaphore = Mutex.new
@students.each do |student|
Thread.new do
notebook_semaphore.synchronize do
student.roll_call(@notebook)
end
end
end
end
end
notebook_semaphore
sẽ đảm bảo tại một thời điểm chỉ có một học sinh có thể ghi vào notebook, khi nó ghi xong sẽ trả lại resource cho bé kế tiếp.
Đây là giải thích sơ khởi nhất cho mô hình Thread and lock, một mô hình rất cơ bản và cấp thấp mà hầu như ngôn ngữ nào cũng hỗ trợ. Ở đây 50 học sinh là các concurrency unit (thread) và cuốn tập là shared resource. Khi một thread cần sử dụng shared resource, nó sẽ lập tức chiếm hữu resource đó để chắc chắn nó là người duy nhất được đụng vào. Mặt khác, một thread nhăm nhe giở trò sở khanh một resource đã bị lock sẽ phải chờ cho đến khi thread khác trả lại resource.
Dùng thread và lock sẽ sát máy (close-to-metal) và hiệu quả nếu dùng đúng. Xin nhắc lại: nếu dùng đúng. Nhưng cân nhắc là rất khó để bạn điều khiển locks đúng và hợp lý. Ví dụ đơn giản của tui với chỉ một resource là “cuốn tập” có thể không giúp bạn thấy được độ khó việc điều khiển lock. Nhưng với hai hoặc nhiều resource hơn, bạn sẽ thấy mô hình này có một số hạn chế nhất định: điển hình là dễ xảy ra deadlock.
Deadlock là nỗi ám ảnh của mọi lập trình viên. Nó là trạng thái mãi chờ nhau của hai hoặc nhiều process khi truy cập tài nguyên. Khi một thread tìm cách giữ nhiều hơn 2 resource, khả năng deadlock xảy ra là rất cao nếu như ta không đủ kinh nghiệm xử lý lock.
Giả sử với lớp 1A ở trên, tui sẽ tăng độ khó của bài toán lên một chút bằng … một cây viết. Một học sinh khi vào lớp sẽ có 2 khả năng xảy ra: tìm cây viết hoặc tìm cuốn tập để điểm danh. Để tui mô phỏng bài toán này cho bạn bằng một đoạn code nhỏ như sau:
class ClassRoom
def start()
notebook_semaphore = Mutex.new
pen_semaphore = Mutex.new
@students.each do |student|
Thread.new do
# Let's shuffle because we don't know if the student would look for
# the pen first or the notebook first.
[notebook_semaphore, pen_semaphore].shuffle.each do |semaphore|
semaphore.synchronize do
student.roll_call(@notebook)
end
end
end
end
end
end
Sau một thời gian, sẽ có bé nào đó giữ cây viết và mãi đi tìm cuốn tập, còn bé đang giữ cuốn tập thì băn khoăn rằng cây viết nằm ở đâu, còn các bé khác thì mải mê chờ hai bé ở trên trong vô vọng.
Tưởng tượng có một cuộc cách mạng về điểm danh xảy ra ở lớp 1A, học sinh trong lớp không còn điểm danh bằng cách tự viết vào cuốn tập nữa mà lớp trưởng sẽ thay các bé làm chuyện đó. Cách làm là như sau:
Mô hình này gọi là Actors model. Với mô hình như vậy, việc ghi/đọc điểm danh của lớp 1A trở nên cực kì đơn giản và trực quan:
Actor là một đơn vị chính của mô hình Actor model và đảm nhận mọi thao tác tính toán trong mô hình này. Các đặc điểm chính của actors bao gồm:
Khi nhận được một message, actor sẽ phải băn khoăn với 3 lựa chọn:
Tuy rằng hệ thống có rất nhiều actors, nhưng trong nội bộ actor mọi thao tác đều là tuần tự. Điều đó có nghĩa là cho dù bạn gửi 10 tin nhắn tới cùng một actor, nó sẽ chỉ sẽ xử lý 1 message cùng lúc. Cách duy nhất để bạn có thể xử lý đồng thời 10 message là tạo ra 10 actor, rồi chia 10 message đó ra cho từng actor.
Elixir là ngôn ngữ chạy trên nền tảng của Erlang VM, thứ đã khiến mô hình Actor trở nên thịnh hành. Về mặt ngôn ngữ thì Erlang không có gì quá nổi bật nếu không muốn nói là cú pháp nhìn mắc ói, rất nhiều boilerplate và stdlib rối tung chảo (điều đó đã được giải quyết với Elixir), nhưng sức mạnh của nó nằm ở OTP, framework được ship cùng với ngôn ngữ để giúp bạn build một hệ thống concurrent, distributed và fault-tolerant.
Actor trong Erlang được gọi process. Là một thực thể của mô hình Actor, một Erlang process cũng tách biệt với thế giới bên ngoài, không share memory, có một mailbox queue và dùng nó để trao đổi message với các process khác.
Erlang process có memory footprint rất nhỏ, khoảng 2KB - 4KB tùy OS. Chúng có thể được khởi tạo (spawn) hay tắt đi (exit) rất nhanh và không làm ảnh hưởng tới performance của hệ thống. Bởi thế người ta hay chém là Erlang VM có khả năng spawn được 134 triệu process.
Giống như các ngôn ngữ dynamic typed khác, Erlang có garbage collection (GC), nhưng mỗi process có một GC riêng. Nó giúp cho việc dọn rác trong Erlang VM không như anh QuickSilver (khi tui chạy cả thế giới như đứng lại), GC của Erlang không stop the world.
Process trong Erlang được định thời bởi Erlang VM scheduler. Scheduler này sẽ chỉ định xem process nào được chạy và process nào không. Đồng thời Erlang scheduler là preemptive, đảm bảo không process nào được phép chạy mãi mãi. Điều này giúp cân bằng thời gian thực thi giữa các task, không có process nào chiếm dụng CPU quá lâu, kể cả regular expression. Tuy vậy một mặt khác nó cũng sinh ra overhead, nhưng mà vì bài này tui đang nâng bi Elixir, nên tui không đi sâu vào phần đó đâu.
Để start một process trong Erlang, bạn có thể dùng hàm spawn
:
def start() do
spawn(fn -> roll_call() end)
end
Chắc đọc tới khúc này sẽ có bạn đặt câu hỏi: Ôi vậy thì khác gì thread nhỉ?. Tui sẽ chửi thầm trong bụng: 🤦♂️, Ơ, vậy nãy giờ ba đang đọc gì vậy?. Nhưng ngoài mặt tui sẽ bảo bạn là câu hỏi rất hay, hãy đọc tiếp bên dưới nhé.
Spawn là bạn tạo ra một process, rồi mặc kệ nó.
Để gửi một tin nhắn tới cho process đã được spawn, bạn có thể dùng hàm send/2
.
iex(1)> chip = spawn(fn ->
...(1)> receive do
...(1)> :yo ->
...(1)> IO.puts("Why call me? Now I die.")
...(1)> exit(:shutdown)
...(1)> end
...(1)> end)
#PID<0.92.0>
iex(2)> send(chip, :yo)
Why call me? Now I die.
:yo
iex(3)> send(chip, :yo)
:yo
CTBDB;CTBCB;CTBDBMGBCB:
receive
là hàm giúp bạn chờ tin nhắn.
CTBDB;CTBCB;CTBDBMGBCB: Có thể bạn đã biết, có thể bạn chưa biết, có thể bạn đã biết mà giả bộ chưa biết.
Khi bạn spawn và link hai process lại với nhau, khi một process nào đó tự nhiên lăn đùng ra chết, process kia sẽ nhận được tin nhắn báo tử.
chip = spawn(fn ->
receive do
:yo ->
IO.puts("Why call me? Now I die.")
Process.exit(self(), :suicide)
end
end)
defmodule Cam do
def start() do
spawn(__MODULE__, :loop, [])
end
def loop() do
Process.flag(:trap_exit, true)
receive do
{:yo, pid} ->
Process.link(pid)
send(pid, :yo)
loop()
{:EXIT, from, reason} ->
IO.inspect("Process #{inspect(from)} is dead <i class="emoji" title=":cry:"><img class="emoji" src="https://twemoji.maxcdn.com/36x36/1f622.png" /></i>, reason: #{inspect(reason)}")
end
end
end
cam = Cam.start()
send(cam, {:yo, chip})
Process #PID<0.31209.11> is dead <i class="emoji" title=":cry:"><img class="emoji" src="https://twemoji.maxcdn.com/36x36/1f622.png" /></i>, reason: :suicide
spawn
, link
, và send
chính là những thành phần cơ bản giúp OTP build Supervisor. Với Supervisor, việc handle lỗi và giữ cho hệ thống luôn sẵn dùng trở nên thật dễ dàng và hiệu quả.
Trong Erlang có một triết lý là “Let It Crash”. Bạn không cần phải lập trình ứng dụng theo cách “cố thủ”, kiểu như phải nghĩ cho ra mọi thứ lỗi có thể xảy ra khi hệ thống chạy và tìm cách xử lý tất cả chúng, bởi vì đơn giản điều đó là không thể. Thay vào đó, hãy dùng Supervisor để quản lý process của bạn và để nó quyết định làm gì khi process bị crash.
Đó là cách để bạn xây dựng một hệ thống “tự phục hồi”. Một process có thể crash vì vô vàn lý do (API down, external service tạm thời không truy cập được, network partition), lúc đó supervisor sẽ hồi sinh và khởi tạo lại state cho nó, từ đó đảm bảo uptime cho ứng dụng của bạn. Joe Armstrong, đồng tác giả của Erlang, nói rằng có service dùng Erlang đã đạt uptime là Nine Nines, 99.999999999% trong vòng 20 năm, tức là 0.63s downtime trong vòng 20 năm. Đệch, ông chém vừa thôi ông Quần Cam, cơ mà link đây.
Tui thích ví von là process của Erlang giống như các tế bào ung thư vậy, chỉ có nước bạn tắt luôn cái máy, bằng không thì chúng nó lại sinh sôi nảy nở, cứ như đống cỏ dại mùa hè vậy.
Một trong những đặc điểm thú vị khác của Elixir/Erlang là nó hỗ trợ phân tán ứng dụng (distributed applications) ngay từ bên trong ngôn ngữ.
Và khi bạn start một ứng dụng Elixir, thật sự là bạn start máy ảo, và máy ảo có thể tạo cho bạn một virtual node. Một cái máy ảo có thể hỗ trợ nhiều virtual node trong cùng một máy, đồng thời một máy có thể connect tới nhiều máy khác, giao tiếp với nhau thông qua giao thức TCP và magic cookies. Cùng với nhau, chúng tạo thành một tập đoàn cứ điểm Điện Biên Phủ và đông như quân Nguyên khắp cụm máy chủ (cluster) của bạn.
Bên cạnh việc phân tán ứng dụng, lập trình phân tán (distributed programming) cũng được Erlang hỗ trợ khá tận răng. Hãy tưởng tượng trên cương vị lập trình viên, khi bạn đang gửi tin nhắn tới một process đích đến, việc process đó nằm cùng máy hay khác máy không thật sự quan trọng, miễn là tin nhắn tới đúng đích. Đi đường nào cũng được, miễn là tới.
Cùng tui kinh qua ví dụ nhỏ sau đây để thấy distributed programming đơn giản như thế nào với Elixir. Hãy bật terminal lên và chạy các lệnh sau:
rrm -rf # Ahihi
iex --sname teo # Now you have node "teo@localhost".
iex --sname tung # Now you also node "tung@localhost".
Ở node “teo”, bạn có thể spawn ra một process, và đăng ký cho nó một cái tên.
cam = spawn(fn ->
receive do
{:yo, from} -> IO.puts "got message from #{from}"
end
end)
Process.register(cam, Cam)
Và bây giờ ở node “tung”, bạn có thể gửi message cho Cam
ở node “teo”.
send({Cam, :"teo@localhost"}, {:ok, Node.self()})
iex(teo@Cams-MacBook-Pro-3)2> Process.register(cam, Cam)
true
got message from tung@localhost
Trước hết, tui cần nói rõ là Concurrency != Parallelism.
Concurrency là làm việc với nhiều task cũng lúc.
Parallelism là xử lý nhiều task cùng lúc.
Giả sử như ở bài toán điểm danh của lớp 1A, đó là bài toán concurrency bởi vì tui chỉ có một cuốn tập và một cây viết. Tui phải vò đầu bứt trán tối ưu hóa thời gian thực hiện việc điểm danh, bằng cách đưa ra luật: học sinh nào tới trước điểm danh trước.
Nhưng đó sẽ trở thành bài toán parallelism nếu như lớp 1A có 50 cuốn tập và 50 cây viết cho 50 học sinh. Lúc này mỗi người sẽ tự ghi tên vào cuốn tập của mình và mối quan tâm của tui là làm sao để chia 50 cây viết và tập cho 50 bé học sinh.
Với Elixir/Erlang, có khả năng bạn sẽ đạt được parallelism đích thực, bởi Erlang VM có thể bật scheduler tùy theo số nhân CPU mà bạn có. Giả sử bạn có 10 nhân CPU và muốn có 10 scheduler, khả năng cao là bạn sẽ có 10 task chạy song song trong cùng một thời điểm.
Bài viết này không giúp bạn tăng lương, cơ mà bài kì này dài quá, tui sẽ tổng kết TL;DR cho các bạn dễ theo dõi.
Bài viết mà thằng chả chém gió về cách chả monitoring và debug một sự cố gặp phải khi vận hành hệ thống Elixir
Bài viết mà thằng chả chém gió về Elixir compiler.
Caching là một kĩ thuật tăng tốc mà hầu như mọi kĩ sư phần mềm đều cần biết. Tuy vậy, đôi khi caching vẫn có thể mang lại cho bạn những vấn đề phiền toái khác như cache stampede.
em đi học elixir đây :3
Chắc phải học lại ELixir :D
Công nhận hôm rồi ngồi nghiên cứu Elixir, mình đã tìm được giải pháp cho vấn đề mình đang bị bí bên Rust :)))
@codeaholicguy @thienlhh đúng rồi, ngay và luôn và tắp lự đi các bác.
@huytd Thấy chưa, mindset của Elixir/Erlang người ta “solve the problem”. Nghiên cứu Elixir để biết rõ thêm mindset nhé.
"Erlang process có memory footprint rất nhỏ, khoảng 2KB - 4KB tùy OS" Nếu như process đang trong quá trình xử lý và cần nhiều hơn lượng memory đó thì phía Erlang sẽ xử lý sao anh Quần Cam?
@g-viet: Về mặt memory thì mỗi process sẽ được cấp một heap, mặc định là 233 words (1 word là 4 hoặc 8 bytes tùy 32 hay 64-bit).
Heap của process có thể grows hoặc shrink trong runtime bởi garbage collector. Chi tiết GC chạy thế nào thì bác có thể xem qua bài viết này nhé: https://www.erlang-solutions.com/blog/erlang-garbage-collector.html.
Nice :)
Thấy bảo elixr bị hay bị 100% cpu nếu chạy mỗi elixir trên 1 máy hoặc giới hạn cpu bằng docker chắc là ổn.
Bạn viết tuyệt vời quá, với ví dụ trực quan như vầy, bạn là một người hiểu rõ vấn đề đến mức thượng thừa
Bác viết hay quá. Em cũng đang học elixir rồi làm con nho nhỏ của công ty, tranh thủ áp dụng công nghệ tuy mới mà cũ 😂