Quần Cam Blog

Elixir/Erlang, Actors model và Concurrency

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:

  • Concurrency và mô hình Thread-Lock.
  • Giới thiệu về mô hình Actors (Actors model).
  • Giới thiệu về Elixir/Erlang.

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.

Bài toán điểm danh

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.

pic-1

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!

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

Thread and lock

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)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

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.

concurrency-deadlock

Actors model

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:

  • Khi một học sinh tới lớp, bé đó sẽ gửi tin nhắn SMS cho lớp trưởng.
  • Lớp trưởng giữ cả viết lẫn cuốn tập.
  • Khi nhận được SMS, lớp trưởng sẽ ghi tên học sinh đó vào cuốn tập.
  • Mỗi lượt lớp trưởng chỉ đọc và xử lý một tin nhắn.
  • Lúc hộp thư trống, lớp trưởng ngồi chơi.
  • Khi cô giáo cần danh sách có mặt, cô cũng sẽ gửi SMS cho lớp trưởng, rồi lớp trưởng sẽ gửi lại danh sách cho cô cũng qua tin nhắn SMS.

concurrency-actor-model

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:

  • Mỗi thành viên trong lớp là một actor trong hệ thống và có một cái mailbox. Khi họ muốn trao đổi gì với nhau, họ gửi tin nhắn vào mailbox của đối phương.
  • Viết và tập giờ trở thành tài sản riêng (private state) của riêng lớp trưởng. KHÔNG ai được phép đọc/ghi vào private state của người khác. Từ đó, lock trở nên thừa thải.

Actor

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:

  • message passing - các actor trao đổi với nhau bằng cách gửi message vào mailbox của nhau. Bạn muốn bảo một actor làm gì cho bạn: gửi message cho nó. Bạn muốn giết một actor: gửi message cho nó. Bạn muốn truy cập thông tin một actor: gửi message cho nó rồi check mailbox.
  • never share memory - mỗi actor có một state riêng mà không có actor nào có thể truy cập hay thay đổi được.
  • there are many actors - Actor là thứ sống theo bầy đàn. Trong mô hình này, tất cả đều là actor hoặc không có một actor nào cả. Đồng thời actor được định danh (giống như bạn được cha mẹ bạn cấp cho cái tên), tui sẽ nói rõ hơn về cái này trong phần tiếp theo.
  • asynchronous - mọi message đều là bất đồng bộ, tức là lúc bạn bấm gửi và lúc nào nó tới là hai chuyện khác nhau.

Khi nhận được một message, actor sẽ phải băn khoăn với 3 lựa chọn:

  • Xử lý thông tin và update state của nó.
  • Tạo thêm các actor khác.
  • Gửi message cho một actor khác.

Mailbox

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

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.

Concurrent

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

Spawn là bạn tạo ra một process, rồi mặc kệ nó.

pic-spawn

Để 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.

Spawn and Link

pic-spawn-link

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="em em-cry"></i>, reason: #{inspect(reason)}")
    end
  end
end

cam = Cam.start()

send(cam, {:yo, chip})
Process #PID<0.31209.11> is dead <i class="em em-cry"></i>, reason: :suicide

pic-trap-exit

Fault tolerance

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.

pic-supervisor

Đó 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.

Distributed

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 "[email protected]".
iex --sname tung  # Now you also node "[email protected]".

Ở 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, :"[email protected]"}, {:ok, Node.self()})
iex([email protected])2> Process.register(cam, Cam)
true
got message from [email protected]

Parallelism

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 sẽ giúp tui tăng lương như thế nào?

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.

  • Học Elixir.
  • Học Elixir.
  • Học Elixir.

🙊


NGUY HIỂM! KHU VỰC NHIỀU GIÓ!
Khuyến cáo giữ chặt bàn phím và lướt thật nhanh khi đi qua khu vực này.
Chức năng này hỗ trợ markdown và các thứ liên quan.
codeaholicguy chém vào lúc

em đi học elixir đây :3

thienlhh chém vào lúc

Chắc phải học lại ELixir :D

huytd chém vào lúc

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 :)))

qcam chém vào lúc

@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é.

g-viet chém vào lúc

"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?

qcam chém vào lúc

@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.

bachnxhedspi chém vào lúc

Nice :)

Bài viết cùng chủ đề

IO data và Vectored IO

Bài viết giới thiệu về IO data, Vectored I/O và tối ưu hóa hệ thống dùng Elixir bằng cách tận dụng Vectored I/O.

Poolboy và kĩ thuật pooling trong Erlang/Elixir

UDP trong Elixir và thủ thuật cache UDP header

Cùng tìm hiểu cách tự build UDP header và tăng 20% performance khi gửi UDP packet trong Elixir/Erlang.