Làm quen với lập trình server và ứng dụng của multithread trong lập trình server

Lâp trình server là một lĩnh vực thú vị, tuy nhiên lại ít được đề cập do không trực tiếp cần thiết trong phát triển ứng dụng. Vì lý do đó có nhiều bạn mặc dù đã quen với rails vẫn còn khá xa lạ với lĩnh vực này. Trong bài viết này mình sẽ hướng dẫn các bạn xây dựng một tiny-puma(phỏng theo mã nguồn của puma) nhằm giúp các bạn hiểu rõ hơn hoạt động của server và ứng dụng lập trình multithread trong server. Qua đó giúp các bạn có một lựa chọn tốt hơn, tối ưu hóa server của mình trong môi trường thực tế.

Xử lý kết nối và hai vấn đề của server trong môi trường thực tế Khi có một kết nối từ client, server sẽ bắt đầu quá trình tải request, khi request hoàn thành, server sẽ gọi ứng dụng của bạn xử lý request, cuối cùng server sẽ trả lại kết quả xử lý đó cho client. Nếu ứng dụng của bạn dùng WEBrick(một single-threaded server), tất cả các xử lý trên sẽ được thực hiện trên duy nhất một thread. Việc này dẫn đến những vấn đề sau:

  • slow client: Nếu một client tải lên một file cực lớn hay đơn giản client sử dụng một đường truyền cực chậm. Server của bạn sẽ bận rộn với việc tiếp nhận dữ liệu từ client đó và không thể tiếp nhận thêm bất kỳ một request nào khác.
  • slow response: Nếu một request nào đó khiến ứng dụng của bạn thực hiện một xử lý rất tốn thời gian như thao tác io, chứng thực qua một server khác..., server của bạn cũng không thể tiếp nhận các request khác.

Trong môi trường thực tế, server của bạn sẽ phải tiếp nhận request từ nhiều client một lúc, để có thể khả dụng, server phải có một cơ chế để giải quyết 2 vấn đề trên nhằm ngăn chặn ảnh hưởng của bất kỳ một client đến toàn bộ hệ thống, và tiếp nhận nhiều kết nối nhất như có thể. Thật may mắn, ruby có rất nhiều server tuyệt vời cho bạn lựa chọn, trong số đó phải kể đến puma, thin. Puma cũng như Thin(ở chế độ multithread) đều sử reactor pattern để đối ứng slow client, và threadpool pattern để đối ứng slow response. Tại rails 5, puma đã chính thức trở thành server mặc định thay thế WEBrick.

Xây dựng tiny-puma
Cấu trúc tiny-puma server được xây dựng sẽ gồm những class sau:

  • Server: theo dõi kết nối, thực hiện xử lý nghiệp vụ và trả kết quả cho client trên một thread của Threadpool.
  • Reactor: chạy trên thread riêng để tải dữ liệu request và chuyển xử lý cho Threadpool khi request hoàn thành.
  • Client: đại diện cho một kết nối từ client, thực hiện các xử lý low-level về tải, trả dữ liệu
  • ThreadPool: thực hiện các xử lý request

Bắt tay vào code...

Bước 1: Server
Hãy bắt đầu bằng việc tạo class Server với nội dung như sau:

#server.rb
Class Server
    def run
      socket = ::TCPServer.new("127.0.0.1", 3000)
      socket.listen 1024

      while true
        ios = IO.select [socket]
        ios.first.each do |sock|
          begin
            if io = sock.accept_nonblock
              @reactor.add Client.new(io)
            end
          rescue Errno::ECONNABORTED
            io.close rescue nil
          end
        end
      end
    end
end    

Hàm run sẽ tạo một server socket lắng nghe tại cổng 3000 và khởi tạo vòng lặp để theo dõi kết nối. Khi xuất hiện một kết nối, server sẽ tạo một Client và chuyển nó cho Reactor để tải request. Bước tiếp theo, chúng ta sẽ hoàn thành Reactor thực hiện việc tải dữ liệu.

Bước 2: Reactor
Tạo class Reactor với nội dung sau:

#reactor.rb
Class Reactor
    def initialize(threadpool)
    @threadpool = threadpool
    @mutex = Mutex.new
    @sockets = []
end

@threadpool là pool xử lý sẽ được truyền cho reactor khi khởi tạo. @sockets là danh sách chờ gồm các client chưa hoàn thành request.

Thêm đoạn code sau ngay trước từ khóa "end" để lần lượt duyệt qua các client và tải request trong event loop của Reactor. Event loop của Reactor hoạt động trên một thread riêng, giúp việc tải đồng thời request trở nên hiệu quả, đồng thời ngăn chặn ảnh hưởng của một slow client đến toàn bộ hệ thống.

Thread.new do
  while true
    if @sockets.size > 0
      ready = IO.select @sockets
      if ready and reads = ready[0]
        reads.each do |c|
          if c.try_to_finish
            @threadpool.add c
            @mutex.synchronize do
              @sockets.delete c
            end
          end
        end
      end
    end
  end
end

Khi việc tải request hoàn thành, client sẽ được xóa khỏi danh sách chờ, và chuyển cho thread pool xử lý.

Cuối cùng chúng ta hoàn thiện class bằng hàm dưới đây để hỗ trợ thêm client vào danh sách chờ từ ngoài Reactor:

def add(client)
  @mutex.synchronize do
    @sockets << client
  end
end

Trong đoạn code trên có bạn sẽ để ý đến sự xuất hiện của @mutex, và thắc mắc tại sao cần sử dụng Mutex. @mutex hoạt động như một khóa để đảm bảo các đoạn code đặt trong @mutex.synchronize sẽ được thực hiện tuần tự, tránh nguy cơ xảy ra lỗi các thread thay đổi thông tin một biến tại cùng một thời điểm. Do xử lý thêm, xóa bỏ client khỏi danh sách @sockets được gọi ở 2 thread khác nhau nên xử lý này cần phải được bọc trong @mutex.synchronize.

Bước 3: Threadpool
Bây giờ chúng ta bắt đầu tạo một ThreadPool để xử lý các request. Hoạt động ThreadPool này vô cùng đơn giản, khi khởi tạo, ThreadPool sẽ tạo sẵn một số lượng min các thread xử lý. Nếu số lượng công việc cần xử lý lớn hơn số lượng thread chờ, pool sẽ sinh thêm thread để xử lý cho tới khi số lượng thread đạt max. Với ThreadPool đơn giản này, số lượng thread trong pool sẽ không được giảm đi một khi đã tăng lên.

Tạo class ThreadPool với nội dung sau:

#theadpool.rb
Class ThreadPool
def initialize(min, max, &block)
  @mutex = Mutex.new
  @have_work = ConditionVariable.new

  @min = min
  @max = max

  #danh sách công việc gồm các client chờ được xử lý
  @works = []

  @waiting = 0
  @spawned = 0

  @block = block

  min.times do
    spawn_thread
  end
end

Tạo hàm add như sau để thêm client vào danh sách công việc. Khi một client mới được thêm, chúng ta sẽ thông báo có công việc mới thông qua @have_work, và tạo thêm thread xử lý nếu cần thiết.

def add(c)
  @mutex.synchronize do
    @works << c
    # thông báo có công việc mới
    @have_work.signal
    #tạo thêm thread nếu số lượng thread chờ nhỏ hơn số công việc cần được xử lý và số lượng thread trong pool nhỏ hơn max
    if @waiting < @works.size and @spawned < @max
      spawn_thread
    end
  end
end

Hoàn thành hàm spawn_thread như dưới để khởi tạo thread. Thread được khởi tạo sẽ xử lý công việc ngay nếu có, hoặc xếp hàng chờ cho tới khi có cộng việc mới.

def spawn_thread
  @spawned += 1
  Thread.new do
    while true do
      work = nil

      @mutex.synchronize do
        while @works.empty?
          @waiting += 1
          @have_work.wait @mutex
          @waiting -= 1
        end
        work = @works.shift
      end

      if work
        @block.call(work)
      end
    end
  end
end

Ở đoạn code trên chúng ta có sử dụng @have_work, môt biến ConditionVariable. ConditionVariable giúp cho thread đang trong xử lý quan trọng có thể tạm dừng và chạy tiếp khi nhận được thông báo. Nếu bạn tưởng tưởng Threadpool như một bến xe, @have_work ở trên hoạt động giống như một người gác cổng chỉ cho xe(thread) xuất bến khi đã đủ hành khách(có công việc mới).

Bước 4: Client Trong Client, chúng ta sẽ tạo 2 hàm đại diện cho việc tải và trả dữ liệu cho client ở mức low-level. Thêm hàm try_to_finish tải thông tin request như bên dưới. Kết quả trả về của hàm cho biết việc tải request đã hoàn thành hay chưa. Việc kiểm tra request hoàn thành trên thực tế sẽ khá phức tạp, nên tiny-puma chỉ đối ứng cho GET request đơn giản, nhờ đó điều kiện kiểm tra chỉ đơn giản là buffer được kết thúc bởi "\r\n\r\n". (Các bạn hãy tự tham khảo thêm để hiểu hơn về HTTP/1.1 protocol)

#client.rb
class Client
    def try_to_finish
      begin
        # tăng thời gian sleep để giả lập slow client
        sleep(0.001)
        data = @io.read_nonblock(5)
      rescue Errno::EAGAIN
        return false
      end

      @buffer << data

      if @buffer.end_with?("\r\n\r\n")
        p "Get request: #{@buffer}"
        true
      else
        false
      end
    end
end

Hoàn thành class bằng hàm response để trả dứ liệu cho client:

def response(result)
  # 200 OK
  @io << 200
  @io << "\n"
  @io << "#{result}\n"
  @io.close
end

Bước 5: Kết nối các thành phần, hoàn thành server Trở lại server.rb file, trong hàm run chúng ta thêm đoạn code sau ngay trước vòng lắp while để khởi tạo Threapool và Reactor. Trong tiny-puma, Server sẽ chỉ thực hiện xử lý nghiệp vụ đơn giản trả về text "Hello" cho client:

#server.rb
@threadpool = TinyPuma::ThreadPool.new(1,10) do |client|
     # tăng thời gian sleep để giả lập long resposne
     sleep(1)
     client.response("Hello")
end

@reactor = TinyPuma::Reactor.new(@threadpool)

Cuối cùng thêm dòng code:

TinyPuma::Server.new.run

vào cuối file server.rb để tạo một server instance.

Sau khi hoàn thành, các bạn có thể tải mã nguồn của tiny-puma tại đây để so sánh: https://github.com/tcuong/tiny_puma

Kiểm tra hoạt động của tiny-puma:

  • Chạy lệnh ruby server.rb để khởi động server.
  • Tại một console khác gõ: curl localhost:3000 Trên console sẽ hiện lên dòng chữ "Hello". Chúc mừng, các bạn đã tạo thành công một tiny puma server.

Hi vọng ví dụ này giúp các bạn bước đầu làm quen với lập trình server và multithread. Nếu có thể các bạn hãy tham khảo thêm mã nguồn của puma, thin để hiểu rõ hơn về những server này nhé. Nắm vững nguyên lý hoạt động của server sẽ giúp bạn tư tin lựa chọn server thích hợp cho ứng dụng của mình cũng như thực hiện các tối ưu hiệu quả khi cần thiết.

Xin cảm ơn.


All Rights Reserved