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 ...
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 " ". (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?(" ") 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 << " " @io << "#{result} " @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.