Một triệu WebSockets và đi

Chào mọi người! Tên tôi là Sergey Kamardin và tôi là nhà phát triển tại Mail.Ru.

Bài viết này là về cách chúng tôi phát triển máy chủ WebSocket tải cao với Go.

Nếu bạn đã quen thuộc với WebSockets, nhưng biết rất ít về Go, tôi hy vọng bạn vẫn sẽ thấy bài viết này thú vị về mặt ý tưởng và kỹ thuật để tối ưu hóa hiệu suất.

1. Giới thiệu

Để xác định bối cảnh câu chuyện của chúng tôi, một vài từ nên được nói về lý do tại sao chúng tôi cần máy chủ này.

Mail.Ru có rất nhiều hệ thống trạng thái. Lưu trữ email người dùng là một trong số đó. Có một số cách để theo dõi các thay đổi trạng thái trong một hệ thống và về các sự kiện hệ thống. Chủ yếu là thông qua thăm dò hệ thống định kỳ hoặc thông báo hệ thống về những thay đổi trạng thái của nó.

Cả hai cách đều có ưu và nhược điểm của chúng. Nhưng khi nói đến thư, người dùng nhận được thư mới càng nhanh thì càng tốt.

Bỏ phiếu qua thư liên quan đến khoảng 50.000 truy vấn HTTP mỗi giây, 60% trong số đó trả về trạng thái 304, nghĩa là không có thay đổi nào trong hộp thư.

Do đó, để giảm tải cho máy chủ và tăng tốc độ gửi thư cho người dùng, quyết định đã được đưa ra để phát minh lại bánh xe bằng cách viết một máy chủ thuê bao của nhà xuất bản (còn được gọi là xe buýt, nhà môi giới tin nhắn hoặc sự kiện- kênh) sẽ nhận được thông báo về các thay đổi trạng thái một mặt và mặt khác đăng ký cho các thông báo đó.

Trước đây:

Hiện nay:

Đề án đầu tiên cho thấy những gì nó giống như trước đây. Trình duyệt định kỳ kiểm tra API và hỏi về các thay đổi về Lưu trữ (dịch vụ hộp thư).

Sơ đồ thứ hai mô tả kiến ​​trúc mới. Trình duyệt thiết lập kết nối WebSocket với API thông báo, là ứng dụng khách đến máy chủ Bus. Khi nhận được email mới, Storage sẽ gửi thông báo về nó cho Bus (1) và Bus cho những người đăng ký của nó (2). API xác định kết nối để gửi thông báo nhận được và gửi nó đến trình duyệt của người dùng (3).

Vì vậy, hôm nay chúng tôi sẽ nói về API hoặc máy chủ WebSocket. Nhìn về phía trước, tôi sẽ nói với bạn rằng máy chủ sẽ có khoảng 3 triệu kết nối trực tuyến.

2. Cách thành ngữ

Hãy để chúng tôi xem cách chúng tôi sẽ triển khai một số phần nhất định trên máy chủ của mình bằng các tính năng Go đơn giản mà không có bất kỳ tối ưu hóa nào.

Trước khi chúng tôi tiến hành net / http, hãy nói về cách chúng tôi sẽ gửi và nhận dữ liệu. Dữ liệu đứng trên giao thức WebSocket (ví dụ: các đối tượng JSON) sau đây sẽ được gọi là các gói.

Hãy bắt đầu triển khai cấu trúc Kênh sẽ chứa logic gửi và nhận các gói như vậy qua kết nối WebSocket.

2.1. Cấu trúc kênh

Tôi muốn thu hút sự chú ý của bạn vào sự ra mắt của hai con khỉ đột đọc và viết. Mỗi con goroutine yêu cầu ngăn xếp bộ nhớ riêng có thể có kích thước ban đầu từ 2 đến 8 KB tùy thuộc vào hệ điều hành và phiên bản Go.

Liên quan đến con số 3 triệu kết nối trực tuyến đã đề cập ở trên, chúng tôi sẽ cần 24 GB bộ nhớ (với ngăn xếp 4 KB) cho tất cả các kết nối. Và mà không có bộ nhớ được phân bổ cho cấu trúc Kênh, các gói gửi đi ch.send và các trường bên trong khác.

2.2. Con khỉ đột I / O

Hãy để chúng tôi có một cái nhìn về việc thực hiện trình đọc của người đọc:

Ở đây, chúng tôi sử dụng bufio.Reader để giảm số lượng các tòa nhà đọc () và để đọc bao nhiêu cho phép theo kích thước bộ đệm buf. Trong vòng lặp vô hạn, chúng tôi hy vọng dữ liệu mới sẽ đến. Hãy nhớ các từ: mong đợi dữ liệu mới sẽ đến. Chúng tôi sẽ trở lại với họ sau.

Chúng tôi sẽ bỏ qua việc phân tích cú pháp và xử lý các gói đến, vì điều đó không quan trọng đối với việc tối ưu hóa mà chúng tôi sẽ nói đến. Tuy nhiên, buf đáng để chúng ta chú ý bây giờ: theo mặc định, nó là 4 KB, nghĩa là thêm 12 GB bộ nhớ cho các kết nối của chúng tôi. Có một tình huống tương tự với "nhà văn":

Chúng tôi lặp đi lặp lại trên kênh gói gửi đi c.send và ghi chúng vào bộ đệm. Đây là, như những người đọc chu đáo của chúng tôi đã có thể đoán, bộ nhớ 4 KB và 12 GB khác cho 3 triệu kết nối của chúng tôi.

2.3. HTTP

Chúng tôi đã có một triển khai Kênh đơn giản, bây giờ chúng tôi cần có kết nối WebSocket để làm việc. Vì chúng ta vẫn đang ở dưới tiêu đề Thành ngữ, chúng ta hãy làm theo cách tương ứng.

Lưu ý: Nếu bạn không biết cách WebSocket hoạt động, thì nên đề cập rằng máy khách chuyển sang giao thức WebSocket bằng cơ chế HTTP đặc biệt có tên là Nâng cấp. Sau khi xử lý thành công yêu cầu Nâng cấp, máy chủ và máy khách sử dụng kết nối TCP để trao đổi các khung WebSocket nhị phân. Dưới đây là một mô tả về cấu trúc khung bên trong kết nối.

Xin lưu ý rằng http.ResponseWriter thực hiện cấp phát bộ nhớ cho bufio.Reader và bufio.Writer (cả hai đều có bộ đệm 4 KB) để khởi tạo * http.Request và viết phản hồi thêm.

Bất kể thư viện WebSocket được sử dụng là gì, sau khi phản hồi thành công yêu cầu Nâng cấp, máy chủ sẽ nhận được bộ đệm I / O cùng với kết nối TCP sau lệnh gọiWWiter.Hijack ().

Gợi ý: trong một số trường hợp, go: linkname có thể được sử dụng để trả lại bộ đệm cho đồng bộ hóa. Bể bơi bên trong mạng / http thông qua cuộc gọi net / http.putBufio {Reader, Writer}.

Do đó, chúng ta cần thêm 24 GB bộ nhớ cho 3 triệu kết nối.

Vì vậy, tổng cộng 72 GB bộ nhớ cho ứng dụng chưa có gì!

3. Tối ưu hóa

Hãy cùng xem lại những gì chúng ta đã nói trong phần giới thiệu và ghi nhớ cách kết nối của người dùng. Sau khi chuyển sang WebSocket, khách hàng sẽ gửi một gói với các sự kiện liên quan hoặc nói cách khác là đăng ký các sự kiện. Sau đó (không tính đến các thông báo kỹ thuật như ping / pong), khách hàng có thể không gửi gì khác cho toàn bộ thời gian kết nối.

Tuổi thọ kết nối có thể kéo dài từ vài giây đến vài ngày.

Vì vậy, trong hầu hết thời gian, Channel.reader () và Channel.writer () của chúng tôi đang chờ xử lý dữ liệu để nhận hoặc gửi. Cùng với họ chờ đợi là bộ đệm I / O 4 KB mỗi bộ.

Bây giờ rõ ràng là một số điều có thể được thực hiện tốt hơn, không thể họ?

3.1. Netpoll

Bạn có nhớ triển khai Channel.reader () dự kiến ​​sẽ có dữ liệu mới bằng cách bị khóa trên cuộc gọi Conn.Read () bên trong bufio.Reader.Read () không? Nếu có dữ liệu trong kết nối, Go runtime "đánh thức" con goroutine của chúng tôi và cho phép nó đọc gói tiếp theo. Sau đó, con goroutine đã bị khóa một lần nữa trong khi chờ đợi dữ liệu mới. Chúng ta hãy xem làm thế nào thời gian chạy Go hiểu rằng con goroutine phải được "đánh thức".

Nếu chúng ta nhìn vào triển khai Conn.Read (), chúng ta sẽ thấy net.netFD.Read () gọi bên trong nó:

Go sử dụng ổ cắm ở chế độ không chặn. EAGAIN nói rằng không có dữ liệu trong ổ cắm và không bị khóa khi đọc từ ổ cắm trống, HĐH trả lại quyền kiểm soát cho chúng tôi.

Chúng tôi thấy một tòa nhà đọc () từ bộ mô tả tệp kết nối. Nếu đọc trả về lỗi EAGAIN, thời gian chạy thực hiện cuộc gọi pollDesc.waitRead ():

Nếu chúng ta đào sâu hơn, chúng ta sẽ thấy netpoll được triển khai bằng epoll trong Linux và kqueue trong BSD. Tại sao không sử dụng cùng một cách tiếp cận cho các kết nối của chúng tôi? Chúng ta có thể phân bổ bộ đệm đọc và chỉ bắt đầu đọc goroutine khi thực sự cần thiết: khi có dữ liệu thực sự có thể đọc được trong ổ cắm.

Trên github.com/golang/go, có vấn đề xuất các hàm netpoll.

3.2. Loại bỏ khỉ đột

Giả sử chúng tôi có triển khai netpoll cho Go. Bây giờ chúng ta có thể tránh bắt đầu goroutine Channel.reader () với bộ đệm bên trong và đăng ký cho sự kiện dữ liệu có thể đọc được trong kết nối:

Nó dễ dàng hơn với Channel.writer () vì chúng ta có thể chạy goroutine và chỉ cấp phát bộ đệm khi chúng ta sẽ gửi gói:

Lưu ý rằng chúng tôi không xử lý các trường hợp khi hệ điều hành trả về EAGAIN khi gọi hệ thống write (). Chúng tôi dựa vào thời gian chạy Go cho các trường hợp như vậy, vì thực sự hiếm khi xảy ra với các loại máy chủ như vậy. Tuy nhiên, nó có thể được xử lý theo cách tương tự nếu cần thiết.

Sau khi đọc các gói gửi đi từ ch.send (một hoặc một vài), người viết sẽ hoàn thành thao tác của nó và giải phóng ngăn xếp goroutine và bộ đệm gửi.

Hoàn hảo! Chúng tôi đã tiết kiệm được 48 GB bằng cách loại bỏ ngăn xếp và bộ đệm I / O bên trong hai con khỉ đột đang chạy liên tục.

3.3. Kiểm soát tài nguyên

Một số lượng lớn các kết nối liên quan đến không chỉ tiêu thụ bộ nhớ cao. Khi phát triển máy chủ, chúng tôi đã trải qua các điều kiện chạy đua và bế tắc lặp đi lặp lại thường được gọi là tự DDoS - một tình huống khi các máy khách ứng dụng cố gắng kết nối với máy chủ, do đó phá vỡ nó nhiều hơn.

Ví dụ: nếu vì một lý do nào đó, chúng tôi đột nhiên không thể xử lý các tin nhắn ping / pong, nhưng trình xử lý các kết nối nhàn rỗi tiếp tục đóng các kết nối đó (giả sử rằng các kết nối bị hỏng và do đó không cung cấp dữ liệu), máy khách dường như mất kết nối mỗi N giây và cố gắng kết nối lại thay vì chờ đợi sự kiện.

Sẽ thật tuyệt nếu máy chủ bị khóa hoặc quá tải chỉ dừng chấp nhận các kết nối mới và bộ cân bằng trước khi nó (ví dụ, nginx) chuyển yêu cầu đến phiên bản máy chủ tiếp theo.

Ngoài ra, bất kể tải máy chủ là bao nhiêu, nếu tất cả khách hàng đột nhiên muốn gửi cho chúng tôi gói vì bất kỳ lý do gì (có thể do nguyên nhân lỗi), 48 GB đã lưu trước đó sẽ được sử dụng lại, vì chúng tôi thực sự sẽ quay lại trạng thái ban đầu của goroutine và bộ đệm cho mỗi kết nối.

Bể bơi Goroutine

Chúng tôi có thể hạn chế số lượng gói được xử lý đồng thời bằng cách sử dụng nhóm goroutine. Đây là những gì một triển khai ngây thơ của hồ bơi như vậy:

Bây giờ mã của chúng tôi với netpoll trông như sau:

Vì vậy, bây giờ chúng tôi đọc gói không chỉ khi xuất hiện dữ liệu có thể đọc được trong ổ cắm, mà còn là cơ hội đầu tiên để chiếm lấy con goroutine miễn phí trong nhóm.

Tương tự, chúng tôi sẽ thay đổi Send ():

Thay vì đi ch.writer (), chúng tôi muốn viết vào một trong những con khỉ đột được sử dụng lại. Do đó, đối với nhóm N goroutines, chúng tôi có thể đảm bảo rằng với N yêu cầu được xử lý đồng thời và N + 1 đã đến, chúng tôi sẽ không phân bổ bộ đệm N + 1 để đọc. Nhóm goroutine cũng cho phép chúng tôi giới hạn Chấp nhận () và Nâng cấp () các kết nối mới và để tránh hầu hết các tình huống với DDoS.

3.4. Nâng cấp không sao chép

Hãy để lệch một chút so với giao thức WebSocket. Như đã đề cập, máy khách chuyển sang giao thức WebSocket bằng cách sử dụng yêu cầu Nâng cấp HTTP. Đây là những gì nó trông giống như:

Đó là, trong trường hợp của chúng tôi, chúng tôi cần yêu cầu HTTP và các tiêu đề của nó chỉ để chuyển sang giao thức WebSocket. Kiến thức này và những gì được lưu trữ bên trong http.Request cho thấy rằng để tối ưu hóa, chúng tôi có thể từ chối phân bổ và sao chép không cần thiết khi xử lý các yêu cầu HTTP và từ bỏ máy chủ net / http tiêu chuẩn.

Ví dụ: http.Request chứa một trường có loại Tiêu đề cùng tên được lấp đầy vô điều kiện với tất cả các tiêu đề yêu cầu bằng cách sao chép dữ liệu từ kết nối sang chuỗi giá trị. Hãy tưởng tượng có bao nhiêu dữ liệu bổ sung có thể được giữ trong trường này, ví dụ như cho tiêu đề Cookie kích thước lớn.

Nhưng lấy lại những gì?

Triển khai WebSocket

Thật không may, tất cả các thư viện hiện có tại thời điểm tối ưu hóa máy chủ của chúng tôi cho phép chúng tôi chỉ nâng cấp cho máy chủ net / http tiêu chuẩn. Hơn nữa, cả hai (hai) thư viện đều không thể sử dụng tất cả các tối ưu hóa đọc và ghi ở trên. Để các tối ưu hóa này hoạt động, chúng ta phải có API cấp độ khá thấp để làm việc với WebSocket. Để sử dụng lại bộ đệm, chúng ta cần các hàm Procotol trông như thế này:

func ReadFrame (io.Reader) (Khung, lỗi)
lỗi func WriteFrame (io.Writer, Frame)

Nếu chúng ta có một thư viện với API như vậy, chúng ta có thể đọc các gói từ kết nối như sau (cách viết gói sẽ giống nhau):

Nói tóm lại, đã đến lúc tạo ra thư viện của riêng chúng tôi.

github.com/gobwas / ws

Về mặt tư tưởng, thư viện ws đã được viết để không áp đặt logic hoạt động giao thức của nó lên người dùng. Tất cả các phương thức đọc và viết đều chấp nhận các giao diện io.Reader và io.Writer tiêu chuẩn, cho phép sử dụng hoặc không sử dụng bộ đệm hoặc bất kỳ trình bao bọc I / O nào khác.

Bên cạnh các yêu cầu nâng cấp từ net / http tiêu chuẩn, ws hỗ trợ nâng cấp không sao chép, xử lý các yêu cầu nâng cấp và chuyển sang WebSocket mà không cần phân bổ bộ nhớ hoặc sao chép. ws.Upgrad () chấp nhận io.ReadWriter (net.Conn thực hiện giao diện này). Nói cách khác, chúng ta có thể sử dụng net.Listen () tiêu chuẩn và chuyển kết nối nhận được từ ln.Accept () ngay lập tức sang ws.Upgrad (). Thư viện cho phép sao chép mọi dữ liệu yêu cầu để sử dụng trong ứng dụng trong tương lai (ví dụ: Cookie để xác minh phiên).

Bên dưới có các điểm chuẩn của xử lý yêu cầu Nâng cấp: máy chủ net / http tiêu chuẩn so với net.Listen () với nâng cấp không sao chép:

Điểm chuẩn Nâng cấpHTTP 5156 ns / op 8576 B / op 9 allocs / op
Điểm chuẩn nâng caoTCP 973 ns / op 0 B / op 0 allocs / op

Chuyển sang nâng cấp ws và zero-copy đã tiết kiệm cho chúng tôi thêm 24 GB - không gian được phân bổ cho bộ đệm I / O khi xử lý yêu cầu của trình xử lý net / http.

3.5. Tóm lược

Hãy để cấu trúc các tối ưu hóa tôi nói với bạn về.

  • Một con goroutine đọc với một bộ đệm bên trong là đắt tiền. Giải pháp: netpoll (epoll, kqueue); tái sử dụng bộ đệm.
  • Một con goroutine viết với một bộ đệm bên trong là đắt tiền. Giải pháp: bắt đầu goroutine khi cần thiết; tái sử dụng bộ đệm.
  • Với một cơn bão kết nối, netpoll đã giành được công việc. Giải pháp: tái sử dụng các con goroutines với giới hạn về số lượng của chúng.
  • net / http không phải là cách nhanh nhất để xử lý Nâng cấp lên WebSocket. Giải pháp: sử dụng nâng cấp không sao chép trên kết nối TCP trần.

Đó là mã máy chủ có thể trông như thế nào:

4. Kết luận

Tối ưu hóa sớm là gốc rễ của mọi tội lỗi (hoặc ít nhất là phần lớn) trong lập trình. Donald Knuth

Tất nhiên, các tối ưu hóa ở trên có liên quan, nhưng không phải trong mọi trường hợp. Ví dụ: nếu tỷ lệ giữa tài nguyên miễn phí (bộ nhớ, CPU) và số lượng kết nối trực tuyến khá cao, có lẽ không có ý nghĩa gì trong việc tối ưu hóa. Tuy nhiên, bạn có thể hưởng lợi rất nhiều từ việc biết nơi cần cải thiện.

Cám ơn vì sự quan tâm của bạn!

5. Tài liệu tham khảo

  • https://github.com/mailru/easygo
  • https://github.com/gobwas/ws
  • https://github.com/gobwas/ws-examples
  • https://github.com/gobwas/httphead
  • Phiên bản tiếng Nga của bài viết này