sách gpt4 ăn đã đi

Hãy nói về Netty và nhìn vào mô hình IO từ góc độ kernel

In lại Tác giả: qq735679552 Thời gian cập nhật: 28-09-2022 22:32:09 25 4
mua khóa gpt4 giày nike

CFSDN nhấn mạnh vào giá trị tạo ra nguồn mở và chúng tôi cam kết xây dựng nền tảng chia sẻ tài nguyên để mọi nhân viên CNTT có thể tìm thấy thế giới tuyệt vời của bạn tại đây.

Bài viết trên blog CFSDN này nói về Netty things và mô hình IO từ góc độ kernel. Nó được tác giả sưu tầm và biên soạn. Nếu bạn quan tâm đến bài viết này, hãy nhớ like nhé.

Hãy nói về Netty và nhìn vào mô hình IO từ góc độ kernel

Bắt đầu từ hôm nay, hãy nói về Netty. Tất cả chúng ta đều biết rằng Netty là một khung mạng hướng sự kiện không đồng bộ hiệu suất cao.

Thiết kế của nó cực kỳ thanh lịch và đơn giản, có khả năng mở rộng cao và độ ổn định mạnh mẽ. Có tài liệu hướng dẫn sử dụng rất chi tiết và đầy đủ.

Đồng thời, nhiều mô-đun rất hữu ích được tích hợp sẵn và có thể sử dụng ngay lập tức. Người dùng chỉ cần viết một vài dòng mã để nhanh chóng xây dựng một mô-đun có thông lượng cao, độ trễ thấp, tiêu thụ ít tài nguyên hơn và hơn thế nữa. hiệu suất cao (Các ứng dụng mạng có tính đồng thời cao với các tính năng như giảm thiểu việc sao chép bộ nhớ không cần thiết).

Trong bài viết này, chúng ta sẽ thảo luận về nền tảng của đặc điểm thông lượng cao và độ trễ thấp của Netty - mô hình IO mạng của netty.

Bắt đầu từ mô hình IO mạng của Netty, chúng tôi sẽ chính thức khởi động loạt bài phân tích mã nguồn Netty này:

Quá trình nhận gói mạng

Hãy nói về Netty và nhìn vào mô hình IO từ góc độ kernel

  • Khi khung dữ liệu mạng đến card mạng thông qua đường truyền mạng, card mạng sẽ đưa khung dữ liệu mạng vào bộ đệm vòng RingBuffer thông qua DMA.

RingBuffer là hàng đợi bộ đệm vòng được card mạng phân bổ và khởi tạo khi nó khởi động. Khi RingBuffer đầy, các gói dữ liệu đến sẽ bị loại bỏ. Chúng ta có thể sử dụng lệnh ifconfig để kiểm tra trạng thái các gói dữ liệu được card mạng gửi và nhận. Mục dữ liệu tràn biểu thị các gói dữ liệu bị loại bỏ khi RingBuffer đầy. Nếu phát hiện mất gói, bạn có thể sử dụng lệnh ethtool để tăng độ dài RingBuffer.

  • Khi hoạt động DMA hoàn tất, card mạng sẽ bắt đầu ngắt cứng tới CPU để báo cho CPU biết rằng dữ liệu mạng đã đến. CPU gọi chương trình phản hồi ngắt cứng được trình điều khiển card mạng đăng ký. Chương trình phản hồi ngắt cứng card mạng sẽ tạo cấu trúc dữ liệu kernel sk_buffer cho khung dữ liệu mạng và sao chép khung dữ liệu mạng vào sk_buffer. Sau đó bắt đầu một yêu cầu ngắt mềm để thông báo cho kernel rằng khung dữ liệu mạng mới đã đến.

Bộ đệm sk_buff là danh sách liên kết hai chiều duy trì cấu trúc khung mạng. Mỗi phần tử trong danh sách liên kết là một khung mạng. Mặc dù ngăn xếp giao thức TCP/IP được chia thành nhiều lớp, việc truyền giữa các lớp khác nhau thực sự chỉ cần vận hành các con trỏ trong cấu trúc dữ liệu này mà không cần sao chép dữ liệu.

  • Luồng hạt nhân ksoftirqd phát hiện rằng một yêu cầu ngắt mềm đã đến và sau đó gọi hàm thăm dò được trình điều khiển card mạng đăng ký. Hàm thăm dò sẽ gửi các gói dữ liệu mạng trong sk_buffer đến hàm ip_rcv đã đăng ký trong ngăn xếp giao thức hạt nhân.

Mỗi CPU sẽ được liên kết với một luồng hạt nhân ksoftirqd dành riêng để xử lý các phản hồi ngắt mềm. Khi có 2 CPU thì sẽ có 2 kernel thread ksoftirqd/0 và ksoftirqd/1.

Có một điều cần lưu ý ở đây: Sau khi card mạng nhận được dữ liệu, khi sao chép DMA hoàn tất, một ngắt cứng sẽ được cấp cho CPU, lúc này CPU nào sẽ phản hồi với ngắt cứng đó, sau đó yêu cầu ngắt mềm sẽ được đưa ra. chương trình phản hồi ngắt cứng của card mạng cũng sẽ phản hồi trong luồng ksoftirqd gắn với CPU này. Do đó, nếu bạn thấy rằng các ngắt mềm của Linux và mức tiêu thụ CPU tập trung vào một lõi, bạn cần điều chỉnh mối quan hệ CPU của các ngắt cứng để phân tán các ngắt cứng sang các lõi CPU khác nhau.

  • Trong hàm ip_rcv, là lớp mạng trong hình trên, lấy tiêu đề IP của gói dữ liệu ra và xác định hướng của bước nhảy tiếp theo của gói dữ liệu. Nếu gói dữ liệu được gửi đến máy cục bộ, hãy lấy ra. loại giao thức của lớp vận chuyển (TCP hoặc UDP), đồng thời loại bỏ tiêu đề IP của gói dữ liệu và chuyển gói dữ liệu đến lớp vận chuyển trong hình trên để xử lý.

Chức năng xử lý của lớp vận chuyển: Giao thức TCP tương ứng với hàm tcp_rcv được đăng ký trong ngăn xếp giao thức hạt nhân và giao thức UDP tương ứng với hàm udp_rcv được đăng ký trong ngăn xếp giao thức hạt nhân.

  • Khi chúng ta sử dụng giao thức TCP, khi gói dữ liệu đến lớp vận chuyển, nó sẽ được xử lý bởi hàm tcp_rcv trong ngăn xếp giao thức kernel. Tiêu đề TCP được loại bỏ trong hàm tcp_rcv Theo bộ bốn (IP nguồn,. cổng nguồn, IP đích, Cổng đích) để tìm kiếm Ổ cắm tương ứng. Nếu tìm thấy Ổ cắm tương ứng, dữ liệu truyền trong gói mạng sẽ được sao chép vào bộ đệm nhận trong Ổ cắm. Nếu không tìm thấy, gói icmp có đích không thể truy cập được sẽ được gửi.
  • Chúng ta đã giới thiệu xong công việc mà kernel thực hiện khi nhận các gói dữ liệu mạng. Bây giờ chúng ta chuyển góc nhìn của mình sang lớp ứng dụng. Khi chương trình của chúng ta đọc dữ liệu trong bộ đệm nhận Socket thông qua lệnh gọi hệ thống, nếu không có dữ liệu trong bộ đệm nhận. nhận bộ đệm, khi đó ứng dụng sẽ chặn lệnh gọi hệ thống cho đến khi có dữ liệu trong bộ đệm nhận Socket, sau đó CPU sao chép dữ liệu trong không gian kernel (Bộ đệm nhận socket) vào không gian người dùng, và cuối cùng việc đọc lệnh gọi hệ thống trả về và ứng dụng đọc dữ liệu.

Chi phí hiệu suất

Đánh giá từ toàn bộ quá trình nhận gói dữ liệu mạng xử lý kernel, kernel đã thực hiện rất nhiều công việc cho chúng tôi và cuối cùng ứng dụng của chúng tôi có thể đọc dữ liệu mạng.

Nó cũng mang lại nhiều chi phí về hiệu suất. Kết hợp với quy trình nhận gói dữ liệu mạng được giới thiệu trước đó, chúng ta hãy xem chi phí hiệu suất trong quá trình nhận gói dữ liệu mạng:

  • Chi phí hoạt động của ứng dụng khi chuyển từ chế độ người dùng sang chế độ kernel thông qua các lệnh gọi hệ thống và chi phí chuyển đổi từ chế độ kernel sang chế độ người dùng khi lệnh gọi hệ thống quay trở lại.
  • Chi phí sao chép dữ liệu mạng từ không gian nhân sang không gian người dùng thông qua CPU.
  • Chi phí hoạt động của luồng hạt nhân ksoftirqd đáp ứng các ngắt mềm.
  • Chi phí hoạt động của CPU đáp ứng với các ngắt cứng.
  • Chi phí hoạt động của DMA sao chép các gói mạng vào bộ nhớ.

Quá trình gửi gói mạng

Hãy nói về Netty và nhìn vào mô hình IO từ góc độ kernel

  • Khi chúng ta gọi lệnh gọi hệ thống gửi để gửi dữ liệu trong ứng dụng, vì đó là lệnh gọi hệ thống nên luồng sẽ trải qua quá trình chuyển đổi từ chế độ người dùng sang chế độ kernel. bản ghi đối tượng Địa chỉ hàm của các ngăn xếp giao thức khác nhau, sau đó xây dựng một đối tượng struct msghdr và đóng gói tất cả dữ liệu mà người dùng cần gửi trong cấu trúc struct msghdr này.
  • Gọi hàm ngăn xếp giao thức hạt nhân inet_sendmsg và quá trình gửi sẽ đi vào ngăn xếp giao thức hạt nhân để xử lý. Sau khi vào ngăn xếp giao thức kernel, kernel sẽ tìm chức năng gửi của giao thức cụ thể trên Socket.

Ví dụ: chúng ta đang sử dụng giao thức TCP và chức năng gửi tương ứng của giao thức TCP là tcp_sendmsg. Nếu là giao thức UDP thì chức năng gửi tương ứng là udp_sendmsg.

  • Trong hàm gửi tcp_sendmsg của giao thức TCP, tạo cấu trúc dữ liệu kernel sk_buffer và sao chép dữ liệu gửi trong cấu trúc struct msghdr sang sk_buffer. Gọi hàm tcp_write_queue_tail để lấy phần tử đuôi trong hàng đợi gửi Ổ cắm và thêm sk_buffer mới được tạo vào đuôi của hàng đợi gửi Ổ cắm.

Hàng đợi gửi của socket là một danh sách liên kết đôi bao gồm sk_buffer.

Tại thời điểm này trong quá trình gửi, dữ liệu mà người dùng muốn gửi cuối cùng cũng được sao chép từ không gian người dùng vào kernel. Mặc dù dữ liệu gửi đã được sao chép vào hàng đợi gửi trong kernel Socket nhưng điều đó không có nghĩa là kernel sẽ làm như vậy. bắt đầu gửi, vì giao thức TCP Để kiểm soát luồng và kiểm soát tắc nghẽn, các gói dữ liệu mà người dùng muốn gửi có thể không được gửi ngay lập tức và phải đáp ứng các điều kiện gửi của giao thức TCP. Nếu các điều kiện gửi không được đáp ứng, lệnh gọi hệ thống gửi này sẽ quay trở lại trực tiếp.

  • Nếu các điều kiện gửi được đáp ứng, hàm kernel tcp_write_xmit sẽ bắt đầu được gọi. Trong chức năng này, sk_buffer được gửi trong hàng đợi gửi Ổ cắm sẽ được lấy trong một vòng lặp, sau đó việc kiểm soát tắc nghẽn và quản lý cửa sổ trượt sẽ được thực hiện.
  • Tạo một bản sao mới của sk_buffer thu được từ hàng đợi gửi Ổ cắm và đặt TIÊU ĐỀ TCP trong bản sao sk_buffer.

sk_buffer thực sự chứa tất cả các tiêu đề trong giao thức mạng. Khi đặt TIÊU ĐỀ TCP, chỉ cần trỏ con trỏ tới vị trí thích hợp của sk_buffer. Khi cài đặt IP HEADER sau này, chỉ cần di chuyển con trỏ để tránh các ứng dụng và bản sao bộ nhớ thường xuyên, rất hiệu quả.

Hãy nói về Netty và nhìn vào mô hình IO từ góc độ kernel

Tại sao không sử dụng trực tiếp sk_buffer trong hàng đợi gửi Socket mà cần tạo một bản sao? Vì giao thức TCP hỗ trợ truyền lại mất gói nên sk_buffer này không thể bị xóa trước khi nhận được ACK từ ngang hàng. Mỗi khi kernel gọi card mạng để gửi dữ liệu, nó thực sự truyền một bản sao của sk_buffer. Khi card mạng gửi dữ liệu, bản sao của sk_buffer sẽ được giải phóng. Sau khi nhận được ACK từ thiết bị ngang hàng, sk_buffer trong hàng đợi gửi Ổ cắm sẽ thực sự bị xóa.

  • Sau khi thiết lập tiêu đề TCP, lớp vận chuyển của ngăn xếp giao thức kernel đã hoàn tất. Tiếp theo, bằng cách gọi hàm kernel ip_queue_xmit, chúng ta chính thức tiến tới quá trình xử lý lớp mạng của ngăn xếp giao thức kernel.

Bạn có thể xem cấu hình định tuyến cục bộ thông qua lệnh lộ trình.

Nếu bạn đã định cấu hình một số quy tắc bằng iptables thì thao tác này sẽ kiểm tra xem các quy tắc đó có khớp hay không. Nếu bạn thiết lập các quy tắc bộ lọc mạng rất phức tạp, chi phí CPU của luồng của bạn sẽ tăng lên rất nhiều trong chức năng này.

  • Di chuyển con trỏ trong sk_buffer đến vị trí tiêu đề IP và đặt tiêu đề IP.
  • Thực hiện lọc bộ lọc mạng. Sau khi vượt qua quá trình lọc, nếu dữ liệu lớn hơn MTU thì việc phân mảnh sẽ được thực hiện.
  • Kiểm tra xem có bảng định tuyến được lưu trong bộ nhớ đệm trong Ổ cắm hay không. Nếu không, hãy tìm mục định tuyến và lưu vào bộ nhớ đệm trong Ổ cắm. Sau đó đặt bảng định tuyến thành sk_buffer.

Sau khi lớp mạng của ngăn xếp giao thức hạt nhân được xử lý, quá trình gửi bây giờ sẽ đi vào hệ thống con lân cận. Hệ thống con lân cận nằm giữa lớp mạng và lớp giao diện mạng trong ngăn xếp giao thức hạt nhân. Nó được sử dụng để gửi các yêu cầu ARP tới. lấy địa chỉ MAC, sau đó Con trỏ trong sk_buffer di chuyển đến vị trí tiêu đề MAC và điền vào tiêu đề MAC.

Sau khi được hệ thống con lân cận xử lý, một khung dữ liệu hoàn chỉnh đã được gói gọn trong sk_buffer, sau đó kernel chuyển sk_buffer cho hệ thống con thiết bị mạng để xử lý. Hệ thống con thiết bị mạng chủ yếu thực hiện những việc sau:

  • Chọn hàng đợi gửi (RingBuffer). Vì card mạng có nhiều hàng đợi gửi nên bạn cần chọn hàng đợi gửi trước khi gửi.
  • Thêm sk_buffer vào hàng gửi.
  • Vòng lặp lấy sk_buffer ra khỏi hàng đợi gửi (RingBuffer) và gọi hàm kernel sch_direct_xmit để gửi dữ liệu, hàm này gọi trình điều khiển card mạng để gửi dữ liệu.

Tất cả quá trình trên được thực thi ở trạng thái kernel của luồng người dùng và thời gian chiếm dụng của CPU là thời gian trạng thái hệ thống (sy). Khi hết hạn ngạch CPU được phân bổ cho luồng người dùng, một ngắt mềm thuộc loại NET_TX_SOFTIRQ sẽ xảy ra. được kích hoạt và luồng nhân ksoftirqd sẽ phản hồi. Ngắt mềm này thực thi hàm gọi lại net_tx_action đã đăng ký với ngắt mềm loại NET_TX_SOFTIRQ. Trong hàm gọi lại, hàm trình điều khiển dev_hard_start_xmit được thực thi để gửi dữ liệu.

Lưu ý: Khi ngắt mềm NET_TX_SOFTIRQ được kích hoạt để gửi dữ liệu, CPU sử dụng sau này sẽ được hiển thị ở đây trong si và thời gian trạng thái hệ thống (sy) của quy trình người dùng sẽ không được sử dụng.

Từ đây, chúng ta có thể thấy rằng quá trình gửi và quá trình nhận các gói mạng là khác nhau. Khi giới thiệu quy trình nhận các gói mạng, chúng tôi đã đề cập rằng ngăn xếp giao thức mạng hạt nhân nhận dữ liệu bằng cách kích hoạt ngắt mềm của loại NET_RX_SOFTIRQ trong luồng hạt nhân. ksoftirqd. Trong quá trình gửi gói dữ liệu mạng, trạng thái kernel của luồng người dùng đang thực thi ngăn xếp giao thức mạng kernel. Chỉ khi hết hạn ngạch CPU của luồng, ngắt mềm NET_TX_SOFTIRQ mới được kích hoạt để gửi dữ liệu.

Trong toàn bộ quá trình gửi và nhận gói mạng, ngắt mềm loại NET_TX_SOFTIRQ sẽ chỉ được kích hoạt khi gói mạng được gửi và khi hết hạn ngạch CPU của luồng người dùng. Các loại ngắt mềm còn lại được kích hoạt trong quá trình nhận và loại ngắt mềm được kích hoạt sau khi gửi dữ liệu đều là NET_RX_SOFTIRQ. Vì vậy, đây là lý do tại sao khi bạn kiểm tra /proc/softirqs trên máy chủ, NET_RX thường lớn hơn NET_TX rất nhiều.

  • Bây giờ quá trình gửi cuối cùng đã đạt đến giai đoạn card mạng thực sự gửi dữ liệu. Chúng tôi đã đề cập trước đó rằng cho dù đó là trạng thái kernel của luồng người dùng hay ngắt mềm của loại NET_TX_SOFTIRQ được kích hoạt khi gửi dữ liệu, thì chức năng trình điều khiển dev_hard_start_xmit của card mạng cuối cùng sẽ được gọi để gửi dữ liệu. Trong chức năng trình điều khiển card mạng dev_hard_start_xmit, sk_buffer được ánh xạ tới vùng DMA bộ nhớ mà card mạng có thể truy cập. Cuối cùng, trình điều khiển card mạng sẽ gửi khung dữ liệu qua card mạng vật lý thông qua DMA.
  • Sau khi dữ liệu được gửi đi, còn một nhiệm vụ quan trọng cuối cùng, đó là dọn dẹp. Sau khi dữ liệu được gửi, thiết bị card mạng sẽ gửi một ngắt cứng đến CPU. CPU gọi chương trình phản hồi ngắt cứng được trình điều khiển card mạng đăng ký, kích hoạt ngắt mềm loại NET_RX_SOFTIRQ trong phản hồi ngắt cứng và xóa sạch. và giải phóng sk_buffer trong hàm gọi lại ngắt mềm igb_poll, xóa hàng đợi gửi card mạng (RingBuffer) và giải phóng ánh xạ DMA.

Bất kể ngắt cứng là do dữ liệu được nhận hay do thông báo hoàn thành, ngắt mềm được kích hoạt từ ngắt cứng là NET_RX_SOFTIRQ.

Những gì được phát hành và làm sạch ở đây chỉ là bản sao của sk_buffer và sk_buffer thực vẫn được lưu trong hàng đợi gửi của Ổ cắm. Chúng tôi đã đề cập trước đây khi xử lý lớp vận chuyển rằng vì lớp vận chuyển cần đảm bảo độ tin cậy nên sk_buffer vẫn chưa bị xóa. Nó phải đợi cho đến khi nhận được ACK từ bên kia trước khi thực sự bị xóa.

Chi phí hiệu suất

Trước đó chúng tôi đã đề cập đến chi phí hiệu suất liên quan đến quá trình nhận các gói mạng. Bây giờ chúng tôi đã giới thiệu quy trình gửi các gói mạng, chúng ta hãy xem chi phí hiệu suất liên quan đến quá trình gửi gói dữ liệu:

Cũng giống như nhận dữ liệu, ứng dụng sẽ chuyển từ chế độ người dùng sang chế độ kernel khi gọi lệnh gọi hệ thống gửi và sau khi gửi dữ liệu, chi phí chuyển từ chế độ kernel sang chế độ người dùng khi lệnh gọi hệ thống quay trở lại.

Khi hết hạn ngạch CPU trạng thái hạt nhân của luồng người dùng, ngắt mềm loại NET_TX_SOFTIRQ sẽ được kích hoạt và hạt nhân sẽ phản hồi chi phí ngắt mềm.

Sau khi card mạng gửi dữ liệu, nó sẽ gửi một ngắt cứng đến CPU và CPU sẽ phản hồi lại phần trên của ngắt cứng. Và gửi ngắt mềm NET_RX_SOFTIRQ trong ngắt cứng để thực hiện các hành động dọn dẹp bộ nhớ cụ thể. Chi phí hoạt động của kernel đáp ứng với softirqs.

Chi phí sao chép bộ nhớ. Hãy xem lại những bản sao bộ nhớ nào xảy ra trong quá trình gửi gói dữ liệu:

  • Trong lớp vận chuyển của ngăn xếp giao thức kernel, hàm gửi tcp_sendmsg tương ứng với giao thức TCP sẽ áp dụng cho sk_buffer và sao chép dữ liệu người dùng muốn gửi vào sk_buffer.
  • Khi quá trình gửi đi từ lớp vận chuyển đến lớp mạng, một bản sao của sk_buffer sẽ được sao chép và truyền xuống. Sk_buffer ban đầu vẫn còn trong hàng đợi gửi Ổ cắm, chờ ACK từ mạng ngang hàng. Sau các ACK ngang hàng, sk_buffer trong hàng đợi gửi Ổ cắm sẽ bị xóa. Nếu thiết bị ngang hàng không gửi ACK, nó sẽ gửi lại từ hàng đợi gửi Ổ cắm để đạt được đường truyền giao thức TCP đáng tin cậy.
  • Ở lớp mạng, nếu nhận thấy dữ liệu cần gửi lớn hơn MTU, nó sẽ thực hiện thao tác phân mảnh, xin thêm sk_buffer và sao chép sk_buffer gốc sang nhiều sk_buffer nhỏ.

Hãy nói lại về (chặn, không chặn) và (đồng bộ, không đồng bộ)

Sau khi nói xong về quá trình nhận và gửi dữ liệu mạng, hãy nói về các khái niệm đặc biệt khó hiểu trong IO: chặn và đồng bộ hóa, không chặn và không đồng bộ.

Có rất nhiều lời giải thích về hai khái niệm này trong các bài đăng blog khác nhau trên Internet và nhiều cuốn sách khác nhau, nhưng tác giả cảm thấy rằng chúng chưa đủ trực quan và chỉ là sự giải thích thẳng thừng về các khái niệm. Nếu các khái niệm được áp dụng một cách cứng nhắc thì thực tế là vậy. cảm giác như chặn và đồng bộ hóa, và không chặn. Vẫn không có sự khác biệt giữa nó và không đồng bộ. Theo thời gian, nó vẫn còn mơ hồ và dễ nhầm lẫn.

Vì vậy, ở đây tôi cố gắng sử dụng một cách trực quan hơn, dễ hiểu và dễ nhớ hơn để giải thích rõ ràng thế nào là chặn và không chặn, thế nào là đồng bộ và không đồng bộ.

Sau phần giới thiệu trước về quy trình nhận gói mạng, ở đây chúng ta có thể tóm tắt toàn bộ quy trình thành hai giai đoạn:

Hãy nói về Netty và nhìn vào mô hình IO từ góc độ kernel

  • Giai đoạn chuẩn bị dữ liệu: Ở giai đoạn này, gói dữ liệu mạng đến card mạng và gói dữ liệu được sao chép vào bộ nhớ thông qua DMA, sau đó đi qua ngắt cứng và ngắt mềm, sau đó được xử lý bởi ngăn xếp giao thức hạt nhân thông qua luồng hạt nhân ksoftirqd và cuối cùng dữ liệu được gửi đến Ổ cắm hạt nhân trong bộ đệm nhận.
  • Giai đoạn sao chép dữ liệu: Khi dữ liệu đến bộ đệm nhận của Ổ cắm hạt nhân, dữ liệu tồn tại trong không gian hạt nhân tại thời điểm này và dữ liệu cần được sao chép vào không gian người dùng trước khi chương trình ứng dụng có thể đọc được.

Chặn và không chặn

Sự khác biệt giữa chặn và không chặn chủ yếu xảy ra ở giai đoạn đầu tiên: giai đoạn chuẩn bị dữ liệu.

Khi ứng dụng bắt đầu đọc lệnh gọi hệ thống, luồng sẽ chuyển từ chế độ người dùng sang chế độ lõi và đọc dữ liệu mạng trong bộ đệm nhận của Ổ cắm lõi.

khối

Nếu không có dữ liệu trong bộ đệm nhận socket kernel tại thời điểm này, luồng sẽ đợi cho đến khi có dữ liệu trong bộ đệm nhận socket. Sau đó, dữ liệu được sao chép từ không gian kernel sang không gian người dùng và lệnh đọc hệ thống sẽ trả về.

Hãy nói về Netty và nhìn vào mô hình IO từ góc độ kernel

Từ hình vẽ, chúng ta có thể thấy rằng việc chặn được đặc trưng bằng việc chờ đợi ở cả giai đoạn đầu tiên và giai đoạn thứ hai.

không chặn

Sự khác biệt chính giữa chặn và không chặn là ở giai đoạn đầu tiên: giai đoạn chuẩn bị dữ liệu.

  • Trong giai đoạn đầu tiên, khi không có dữ liệu trong bộ đệm nhận của Socket, luồng ứng dụng sẽ đợi mãi trong chế độ chặn. Ở chế độ không chặn, luồng ứng dụng sẽ không chờ và lệnh gọi hệ thống trực tiếp trả về cờ lỗi EWOULDBLOCK.
  • Khi có dữ liệu trong bộ đệm nhận của Ổ cắm, hiệu suất chặn và không chặn là như nhau. Chúng sẽ chuyển sang giai đoạn thứ hai và đợi dữ liệu được sao chép từ không gian kernel sang không gian người dùng, sau đó cuộc gọi hệ thống sẽ trả về. .

Hãy nói về Netty và nhìn vào mô hình IO từ góc độ kernel

Từ hình trên, chúng ta có thể thấy đặc điểm của non-blocking là nó sẽ không đợi ở giai đoạn đầu tiên mà vẫn đợi ở giai đoạn thứ hai.

Đồng bộ và không đồng bộ

Sự khác biệt chính giữa đồng bộ và không đồng bộ xảy ra ở giai đoạn thứ hai: giai đoạn sao chép dữ liệu.

Chúng tôi đã đề cập trước đó rằng giai đoạn sao chép dữ liệu chủ yếu sao chép dữ liệu từ không gian kernel sang không gian người dùng. Chỉ khi đó ứng dụng mới có thể đọc dữ liệu.

Khi dữ liệu đến bộ đệm nhận của kernel socket, giai đoạn thứ hai sẽ được nhập.

đồng bộ

Ở chế độ đồng bộ, sau khi dữ liệu sẵn sàng, giai đoạn thứ hai được thực thi bởi trạng thái kernel của luồng người dùng. Do đó, ứng dụng sẽ chặn ở giai đoạn thứ hai và lệnh gọi hệ thống sẽ không quay trở lại cho đến khi dữ liệu được sao chép từ không gian kernel sang không gian người dùng.

epoll trong Linux và kqueue trong Mac đều thuộc IO đồng bộ.

Hãy nói về Netty và nhìn vào mô hình IO từ góc độ kernelHãy nói về Netty và nhìn vào mô hình IO từ góc độ kernel


không đồng bộ

Ở chế độ không đồng bộ, kernel thực hiện giai đoạn thứ hai của thao tác sao chép dữ liệu. Khi kernel hoàn thành giai đoạn thứ hai, nó sẽ thông báo cho luồng người dùng rằng thao tác IO đã hoàn thành và gọi lại dữ liệu cho luồng người dùng. Do đó, ở chế độ không đồng bộ, giai đoạn chuẩn bị dữ liệu và giai đoạn sao chép dữ liệu được hạt nhân hoàn thành và sẽ không gây ra bất kỳ sự chặn nào đối với chương trình ứng dụng.

Dựa vào những đặc điểm trên, chúng ta có thể thấy chế độ không đồng bộ yêu cầu hỗ trợ kernel và phụ thuộc nhiều hơn vào sự hỗ trợ cơ bản của hệ điều hành.

Trong số các hệ điều hành phổ biến hiện nay, chỉ IOCP trong Windows là IO không đồng bộ thực sự và việc triển khai nó cũng rất hoàn thiện. Nhưng Windows hiếm khi được sử dụng làm máy chủ.

Trong Linux, thường được sử dụng làm máy chủ, cơ chế IO không đồng bộ chưa đủ trưởng thành và sự cải thiện hiệu suất so với NIO là chưa đủ rõ ràng.

Tuy nhiên, trong phiên bản 5.1 của nhân Linux, thư viện IO không đồng bộ mới io_uring đã được chủ nhân Facebook Jens Axboe giới thiệu, giúp cải thiện một số vấn đề về hiệu suất của AIO gốc Linux ban đầu. So với Epoll và AIO bản địa trước đó, hiệu suất đã được cải thiện rất nhiều, điều này đáng được quan tâm.

Hãy nói về Netty và nhìn vào mô hình IO từ góc độ kernel


mô hình IO

Khi thực hiện các hoạt động IO mạng, mô hình IO nào được sử dụng để đọc và ghi dữ liệu sẽ quyết định phần lớn hiệu suất IO của khung mạng. Vì vậy, việc lựa chọn mô hình IO là cơ sở để xây dựng framework mạng hiệu suất cao.

Trong cuốn sách "Lập trình mạng UNIX", năm mô hình IO được giới thiệu: chặn IO, IO không chặn, ghép kênh IO, IO điều khiển tín hiệu và IO không đồng bộ. Sự xuất hiện của mỗi mô hình IO là sự phản ánh của Nâng cấp trước đó. và tối ưu hóa.

Tiếp theo, chúng tôi sẽ giới thiệu những vấn đề mà mỗi mô hình IO trong số 5 mô hình IO này giải quyết được, chúng phù hợp với những tình huống nào và những ưu điểm cũng như nhược điểm tương ứng của chúng là gì?

Chặn IO(BIO)

Hãy nói về Netty và nhìn vào mô hình IO từ góc độ kernel


Sau khi giới thiệu khái niệm chặn ở phần trước, tôi tin mọi người có thể dễ dàng hiểu được khái niệm và quy trình chặn IO.

Vì chúng ta đang nói về IO trong phần này nên chúng ta hãy xem quá trình đọc và ghi dữ liệu mạng theo mô hình chặn IO.

chặn đọc

Khi luồng người dùng bắt đầu lệnh gọi hệ thống đọc, luồng người dùng sẽ chuyển từ trạng thái người dùng sang trạng thái kernel và kiểm tra trong kernel xem có dữ liệu đến bộ đệm nhận socket hay không.

  • Nếu có dữ liệu trong bộ đệm nhận Socket, luồng người dùng sẽ sao chép dữ liệu trong không gian kernel sang không gian người dùng ở trạng thái kernel và lệnh gọi IO của hệ thống sẽ trả về.
  • Nếu không có dữ liệu trong bộ đệm nhận Socket, luồng người dùng sẽ từ bỏ CPU và chuyển sang trạng thái chặn. Khi dữ liệu đến bộ đệm nhận Socket, kernel sẽ đánh thức luồng người dùng ở trạng thái bị chặn và chuyển sang trạng thái sẵn sàng. Sau đó, nó nhận được hạn ngạch CPU thông qua lập lịch CPU và chuyển sang trạng thái đang chạy, sao chép dữ liệu trong không gian kernel vào. không gian người dùng và sau đó cuộc gọi hệ thống sẽ trả về.

chặn viết

Khi một luồng người dùng bắt đầu cuộc gọi hệ thống gửi, luồng người dùng sẽ chuyển từ chế độ người dùng sang chế độ kernel và sao chép dữ liệu gửi từ không gian người dùng sang bộ đệm gửi socket trong không gian kernel.

  • Khi bộ đệm gửi ổ cắm có thể chứa dữ liệu gửi, luồng người dùng sẽ ghi tất cả dữ liệu gửi vào bộ đệm ổ cắm, sau đó thực hiện quy trình tiếp theo được giới thiệu trong phần "Quy trình gửi gói mạng" rồi quay lại.
  • Khi không gian bộ đệm gửi socket không đủ để chứa tất cả dữ liệu đã gửi, luồng người dùng sẽ từ bỏ CPU và chuyển sang trạng thái chặn. Khi bộ đệm gửi socket có thể chứa tất cả dữ liệu đã gửi, kernel sẽ đánh thức luồng người dùng và thực thi. quá trình gửi tiếp theo.

Kiểu thao tác ghi theo mô hình IO chặn tương đối cứng nhắc và tất cả dữ liệu gửi phải được ghi vào bộ đệm gửi trước khi từ bỏ.

Chặn mô hình IO

Hãy nói về Netty và nhìn vào mô hình IO từ góc độ kernel

Do đặc tính đọc và ghi của việc chặn IO, theo mô hình IO chặn, mỗi yêu cầu cần được xử lý bởi một luồng độc lập. Một luồng chỉ có thể được liên kết với một kết nối cùng một lúc. Khi có yêu cầu, máy chủ cần tạo một luồng để xử lý yêu cầu.

Khi khối lượng yêu cầu đồng thời của máy khách tăng đột ngột, máy chủ sẽ tạo một số lượng lớn luồng ngay lập tức và việc tạo luồng yêu cầu chi phí tài nguyên hệ thống, điều này sẽ chiếm một lượng lớn tài nguyên hệ thống ngay lập tức.

Nếu máy khách không gửi dữ liệu sau khi tạo kết nối, thông thường trong hầu hết các trường hợp, kết nối mạng không phải lúc nào cũng có dữ liệu để đọc nên trong thời gian nhàn rỗi, luồng máy chủ sẽ vẫn bị chặn, không thể thực hiện các việc khác. CPU không thể được sử dụng hết công suất và nó cũng sẽ gây ra nhiều chi phí chuyển đổi luồng.

Các tình huống áp dụng

Dựa trên các đặc điểm trên của mô hình chặn IO, mô hình này chỉ phù hợp với các kịch bản kinh doanh có số lượng kết nối nhỏ và tính đồng thời thấp.

Ví dụ: đối với một số hệ thống quản lý trong công ty, số lượng yêu cầu thường vào khoảng 100, do đó rất phù hợp khi sử dụng mô hình chặn IO. Và hiệu suất không hề thua kém NIO.

Mô hình này là mô hình IO được áp dụng phổ biến trước C10K.

IO không chặn (NIO)

Vấn đề lớn nhất với mô hình IO chặn là một luồng chỉ có thể xử lý một kết nối. Nếu không có dữ liệu trên kết nối này thì luồng này chỉ có thể bị chặn trong lệnh gọi IO của hệ thống và không thể thực hiện những việc khác. Đây là một sự lãng phí rất lớn tài nguyên hệ thống. Đồng thời, một số lượng lớn các thread context switch cũng tiêu tốn một lượng lớn chi phí hệ thống.

Vì vậy, để giải quyết vấn đề này, chúng ta cần sử dụng càng ít luồng càng tốt để xử lý nhiều kết nối hơn. , sự phát triển của mô hình IO mạng cũng đang phát triển từng bước dựa trên nhu cầu này.

Dựa trên nhu cầu này, giải pháp đầu tiên, IO không chặn, đã xuất hiện. Chúng tôi đã giới thiệu khái niệm không chặn trong phần trước. Bây giờ chúng ta hãy xem xét các đặc điểm của hoạt động đọc và ghi mạng trong IO không chặn:

Hãy nói về Netty và nhìn vào mô hình IO từ góc độ kernelHãy nói về Netty và nhìn vào mô hình IO từ góc độ kernel


đọc không chặn

Khi luồng người dùng bắt đầu lệnh gọi hệ thống đọc không chặn, luồng người dùng sẽ chuyển từ chế độ người dùng sang chế độ hạt nhân và kiểm tra trong hạt nhân xem dữ liệu đã đến bộ đệm nhận Ổ cắm hay chưa.

  • Không có dữ liệu trong bộ đệm nhận Ổ cắm và lệnh gọi hệ thống sẽ trả về ngay lập tức với lỗi EWOULDBLOCK hoặc EAGAIN Ở giai đoạn này, luồng người dùng sẽ không chặn hoặc từ bỏ CPU mà sẽ tiếp tục đào tạo cho đến khi có dữ liệu trong bộ đệm. Ổ cắm nhận bộ đệm cho đến khi.
  • Có dữ liệu trong bộ đệm nhận Socket. Luồng người dùng sẽ sao chép dữ liệu trong không gian kernel sang không gian người dùng ở trạng thái kernel. Lưu ý rằng trong giai đoạn sao chép dữ liệu này, ứng dụng sẽ bị chặn. cuộc gọi hệ thống trả về.

ghi không chặn

Khi chúng tôi giới thiệu việc chặn việc ghi trước đó, chúng tôi đã đề cập rằng phong cách ghi chặn là đặc biệt khó khăn. Cần phải ghi tất cả dữ liệu gửi vào bộ đệm gửi của Ổ cắm cùng một lúc trước khi quay trở lại. Nếu không có đủ dung lượng trong bộ đệm gửi. Nếu bạn muốn chứa nó, thì bạn sẽ luôn bị chặn và chờ đợi.

Để so sánh, đặc điểm của văn bản không chặn là mang tính Phật giáo hơn. Khi không có đủ không gian trong bộ đệm gửi để chứa tất cả dữ liệu đã gửi, đặc điểm của văn bản không chặn là nó có thể viết nhiều nhất có thể và nó sẽ quay lại ngay nếu không thể viết được nữa. Và trả về số byte được ghi vào bộ đệm gửi cho chương trình ứng dụng, để luồng người dùng có thể liên tục cố gắng ghi dữ liệu còn lại vào bộ đệm gửi.

Mô hình IO không chặn

Hãy nói về Netty và nhìn vào mô hình IO từ góc độ kernel

Dựa vào đặc điểm trên của non-blocking IO, chúng ta không cần cấp phát thread cho mỗi request để xử lý việc đọc ghi trên kết nối như chặn IO.

Chúng ta có thể sử dụng một luồng hoặc rất ít luồng để liên tục thăm dò bộ đệm nhận của từng Ổ cắm để xem dữ liệu đã đến chưa. Nếu không có dữ liệu thì không cần chặn luồng mà phải tiếp tục thăm dò bộ đệm nhận của Ổ cắm tiếp theo cho đến khi. Sau khi thăm dò dữ liệu, xử lý việc đọc và ghi trên kết nối hoặc chuyển nó đến nhóm luồng nghiệp vụ để xử lý và luồng thăm dò tiếp tục thăm dò các bộ đệm nhận Ổ cắm khác.

Mô hình IO không chặn như vậy đáp ứng các yêu cầu mà chúng tôi đưa ra ở đầu phần này: chúng tôi cần sử dụng càng ít luồng càng tốt để xử lý nhiều kết nối hơn.

Các tình huống áp dụng

Mặc dù mô hình IO không chặn giúp giảm phần lớn mức tiêu thụ tài nguyên và chi phí hệ thống so với mô hình IO chặn.

Nhưng nó vẫn có những vấn đề lớn về hiệu suất, vì theo mô hình IO không chặn, luồng người dùng bắt buộc phải liên tục khởi tạo các lệnh gọi hệ thống để thăm dò bộ đệm nhận Socket, yêu cầu luồng người dùng phải liên tục chuyển từ chế độ người dùng sang chế độ kernel. sang chế độ người dùng. Khi số lượng đồng thời tăng lên, chi phí chuyển đổi ngữ cảnh cũng rất lớn.

Do đó, mô hình IO không chặn đơn giản vẫn không phù hợp với các kịch bản có tính tương tranh cao. Nó chỉ có thể được áp dụng cho những cảnh dưới C10K.

ghép kênh IO

Ở đầu phần về IO không chặn, chúng tôi đã đề cập rằng sự phát triển của mô hình IO mạng xoay quanh yêu cầu cốt lõi về cách xử lý nhiều kết nối hơn với càng ít luồng càng tốt.

Trong phần này chúng ta sẽ nói về mô hình ghép kênh IO vậy ghép kênh là gì?

Chúng tôi vẫn sử dụng yêu cầu cốt lõi này để giải thích chi tiết về hai khái niệm sau:

  • Ghép kênh:Yêu cầu cốt lõi của chúng tôi là sử dụng càng ít luồng càng tốt để xử lý nhiều kết nối nhất có thể. Đa đường dẫn ở đây đề cập đến nhiều kết nối mà chúng tôi cần xử lý.
  • Tái sử dụng:Các yêu cầu cốt lõi yêu cầu chúng tôi sử dụng càng ít luồng càng tốt và càng ít chi phí hệ thống càng tốt để xử lý nhiều kết nối (nhiều kênh) nhất có thể. Vì vậy, việc tái sử dụng ở đây đề cập đến việc sử dụng các tài nguyên hạn chế, chẳng hạn như sử dụng một luồng hoặc một số lượng cố định. Chủ đề xử lý các sự kiện đọc và ghi trên nhiều kết nối. Nói cách khác, trong mô hình IO chặn, một kết nối cần được phân bổ một luồng độc lập để xử lý cụ thể việc đọc và ghi trên kết nối này. Trong mô hình ghép kênh IO, nhiều kết nối có thể sử dụng lại luồng độc lập này để xử lý việc đọc và ghi trên các kết nối này. nhiều kết nối.

Được rồi, khái niệm về mô hình ghép kênh IO đã được giải thích rõ ràng, vậy mấu chốt của vấn đề là làm cách nào để triển khai việc ghép kênh này, tức là làm cách nào để một luồng độc lập xử lý các sự kiện đọc và ghi trên nhiều kết nối?

Câu hỏi này thực tế đã được trả lời trong mô hình IO không chặn. Trong mô hình IO không chặn, các lệnh gọi IO của hệ thống không chặn được sử dụng để liên tục thăm dò bộ đệm nhận Socket của nhiều kết nối để xem liệu có dữ liệu nào đến không. có một cái thì xử lý, nếu không có thì tiếp tục thăm dò Socket tiếp theo. Bằng cách này, một luồng có thể được sử dụng để xử lý các sự kiện đọc và ghi trên nhiều kết nối.

Tuy nhiên, vấn đề lớn nhất của mô hình IO không chặn là nó cần liên tục khởi tạo các lệnh gọi hệ thống để thăm dò bộ đệm nhận trong mỗi Ổ cắm để xử lý các lệnh gọi hệ thống thường xuyên mang lại nhiều chi phí chuyển đổi ngữ cảnh. Khi số lượng đồng thời tăng lên, điều này cũng sẽ gây ra các vấn đề hiệu suất rất nghiêm trọng.

Vậy làm cách nào chúng ta có thể tránh được các cuộc gọi hệ thống thường xuyên trong khi vẫn đạt được các nhu cầu cốt lõi của mình?

Điều này yêu cầu nhân hệ điều hành hỗ trợ các hoạt động như vậy. Chúng tôi có thể chuyển giao các hoạt động thăm dò thường xuyên cho nhân hệ điều hành để hoàn thành nó cho chúng tôi, do đó tránh được việc thường xuyên sử dụng các lệnh gọi hệ thống để thăm dò trong không gian hiệu suất của người dùng.

Như chúng ta đã nghĩ, nhân hệ điều hành cung cấp cho chúng ta khả năng triển khai chức năng như vậy. Chúng ta hãy xem việc triển khai mô hình ghép kênh IO của hệ điều hành.

lựa chọn

select là một lệnh gọi hệ thống do kernel hệ điều hành cung cấp để chúng ta sử dụng. Nó giải quyết vấn đề về không gian người dùng và không gian kernel do nhu cầu liên tục khởi tạo các lệnh gọi IO hệ thống để thăm dò bộ đệm nhận Socket trên mỗi kết nối trong chế độ không chặn. Mô hình IO. Chi phí hệ thống của chuyển mạch liên tục.

Lệnh gọi hệ thống chọn chuyển giao hoạt động thăm dò ý kiến ​​cho hạt nhân để giúp chúng tôi hoàn thành nó, nhờ đó tránh được chi phí hoạt động của hệ thống do liên tục bắt đầu thăm dò ý kiến ​​trong không gian người dùng.

Hãy nói về Netty và nhìn vào mô hình IO từ góc độ kernelHãy nói về Netty và nhìn vào mô hình IO từ góc độ kernel


  • Đầu tiên, luồng người dùng sẽ chặn cuộc gọi hệ thống chọn khi nó bắt đầu cuộc gọi hệ thống chọn. Tại thời điểm này, luồng người dùng chuyển từ chế độ người dùng sang chế độ kernel và hoàn thành chuyển đổi ngữ cảnh.
  • Luồng người dùng chuyển mảng fd mô tả tệp tương ứng với Ổ cắm cần được giám sát tới kernel thông qua lệnh gọi hệ thống chọn. Tại thời điểm này, luồng người dùng sao chép mảng fd mô tả tệp trong không gian người dùng sang không gian kernel.

Mảng mô tả tệp ở đây thực sự là một BitMap. Chỉ số dưới BitMap là mô tả tệp fd. Giá trị tương ứng của chỉ số dưới là: 1 cho biết có các sự kiện đọc và ghi trên fd và 0 cho biết không có sự kiện đọc và ghi. sự kiện trên fd.

Hãy nói về Netty và nhìn vào mô hình IO từ góc độ kernel

Bộ mô tả tệp fd thực sự là một giá trị số nguyên. Trong Linux, mọi thứ đều là một tệp và Socket cũng là một tệp. Có một thuộc tính struct files_struct *files trong cấu trúc dữ liệu task_struct mô tả tất cả thông tin của tiến trình. Cuối cùng, mảng này lưu trữ danh sách tất cả các tệp được mở bởi tiến trình. cấu trúc tệp. Kiểu của mảng này được lưu trữ. Nó là cấu trúc tệp cấu trúc và chỉ số dưới của mảng là cái mà chúng ta thường gọi là bộ mô tả tệp fd.

  • Khi luồng người dùng bắt đầu chuyển sang trạng thái chặn sau khi gọi select, kernel bắt đầu thăm dò và duyệt qua mảng fd để xem liệu có dữ liệu nào đến bộ đệm nhận Socket tương ứng với fd hay không. Nếu dữ liệu đến, giá trị của BitMap tương ứng với fd được đặt thành 1. Nếu không có dữ liệu nào đến, giá trị vẫn là 0.

Lưu ý: Kernel ở đây sẽ sửa đổi mảng fd ban đầu!.

  • Sau khi kernel đi qua mảng fd, nếu nó thấy dữ liệu IO đã đến trên một số fds, nó sẽ trả về mảng fd đã sửa đổi cho luồng người dùng. Lúc này, mảng fd sẽ được sao chép từ không gian kernel sang không gian người dùng.
  • Khi kernel trả về mảng fd đã sửa đổi cho luồng người dùng, luồng người dùng sẽ được bỏ chặn và luồng người dùng bắt đầu duyệt qua mảng fd rồi tìm bộ mô tả tệp Ổ cắm có giá trị 1 trong mảng fd. Cuối cùng, các lệnh gọi hệ thống được bắt đầu trên các Ổ cắm này để đọc dữ liệu.

Chọn sẽ không cho luồng người dùng biết fds nào có dữ liệu IO đang đến. Nó chỉ đánh dấu các fds có IO đang hoạt động và trả về mảng fd đã đánh dấu cho luồng người dùng. Do đó, luồng người dùng cũng cần duyệt qua mảng fd để tìm ra mảng fd nào. Dữ liệu IO đến trên fd.

  • Vì kernel đã sửa đổi mảng fd trong quá trình truyền tải, nên sau khi luồng người dùng đã nhận được Ổ cắm sẵn sàng IO sau khi duyệt qua mảng fd, nó cần đặt lại mảng fd và gọi lại select để chuyển vào mảng fd đặt lại. kernel bắt đầu một vòng bỏ phiếu truyền tải mới.

Giới thiệu API

Khi chúng ta đã quen với nguyên tắc chọn, sẽ dễ hiểu API chọn do kernel cung cấp.

 số nguyên lựa chọn(số nguyên maxfdp1,fd_bộ *đọc lại,fd_bộ *viết tập,fd_bộ *ngoại trừ tập hợp,hằng số cấu trúc khoảng thời gian *thời gian chờ)

Từ API chọn lọc, chúng ta có thể thấy rằng lệnh gọi hệ thống chọn lọc sẽ giám sát (thăm dò ý kiến) các sự kiện có thể đọc, có thể ghi và bất thường trên bộ mô tả tệp mà người dùng quan tâm trong khoảng thời gian chờ đã chỉ định.

  • maxfdp1 : Lựa chọn được chuyển tới bộ mô tả tệp có giá trị lớn nhất trong tập hợp các bộ mô tả tệp được giám sát bởi kernel + 1, được sử dụng để giới hạn phạm vi truyền tải kernel. Ví dụ: tập hợp các bộ mô tả tệp được giám sát bởi select là {0,1,2,3,4} thì giá trị của maxfdp1 là 5.
  • fd_set *đọc thiết lập: Một tập hợp các bộ mô tả tập tin quan tâm đến các sự kiện có thể đọc được.
  • fd_set *writeset: Một tập hợp các bộ mô tả tập tin quan tâm đến các sự kiện có thể ghi.
  • fd_set *exceptset:Một tập hợp các bộ mô tả tập tin quan tâm đến các sự kiện có thể ghi.

Fd_set ở đây là mảng mô tả tệp mà chúng tôi đã đề cập trước đó, là cấu trúc BitMap.

  • const struct timeval *timeout:Khoảng thời gian chờ cuộc gọi hệ thống được chọn Trong khoảng thời gian này, nếu kernel không tìm thấy bộ mô tả tệp sẵn sàng cho IO, nó sẽ trả về trực tiếp.

Như đã đề cập trong phần trước, sau khi kernel duyệt qua mảng fd và tìm thấy một fd sẵn sàng cho IO, nó sẽ đặt giá trị trong BitMap tương ứng với fd thành 1 và trả về mảng fd đã sửa đổi cho luồng người dùng.

Trong luồng người dùng, cần phải duyệt lại mảng fd, tìm ra fd sẵn sàng IO và sau đó bắt đầu lệnh gọi đọc và ghi thực sự.

Phần sau đây giới thiệu API mà chúng ta cần sử dụng trong quá trình duyệt lại mảng fd trong luồng người dùng:

  • lệnh void FD_ZERO(fd_set *fdset):Xóa bộ mô tả tệp đã chỉ định, nghĩa là để fd_set không còn chứa bất kỳ bộ mô tả tệp nào nữa.
  • void FD_SET(int fd, fd_set *fdset):Thêm một bộ mô tả tập tin nhất định vào bộ sưu tập.

Bộ mô tả tệp phải được đặt lại thông qua FD_ZERO và FD_SET trước mỗi lệnh gọi để chọn, vì bộ mô tả tệp sẽ được sửa đổi trong kernel.

  • int FD_ISSET(int fd, fd_set *fdset):Kiểm tra xem bộ mô tả tệp được chỉ định trong bộ sưu tập có thể đọc và ghi được hay không. Luồng người dùng duyệt qua bộ sưu tập bộ mô tả tệp và gọi phương thức này để kiểm tra xem bộ mô tả tệp tương ứng đã sẵn sàng IO chưa.
  • void FD_CLR(int fd, fd_set *fdset):Xóa bộ mô tả tệp nhất định khỏi bộ

Chi phí hiệu suất

Mặc dù select giải quyết được vấn đề thường xuyên khởi tạo các lệnh gọi hệ thống trong mô hình IO không chặn, nhưng trong toàn bộ quá trình làm việc của select, chúng ta vẫn thấy một số thiếu sót của select.

  • Khi bắt đầu cuộc gọi hệ thống đã chọn và quay lại, luồng người dùng phải chịu chi phí chuyển ngữ cảnh từ chế độ người dùng sang chế độ kernel và chế độ kernel sang chế độ người dùng. Đã xảy ra 2 chuyển đổi ngữ cảnh
  • Khi bắt đầu cuộc gọi hệ thống đã chọn và quay lại, luồng người dùng cần sao chép bộ mô tả tệp được đặt từ không gian người dùng sang không gian kernel ở chế độ kernel. Và sau khi kernel sửa đổi bộ mô tả tệp, nó cần sao chép nó từ không gian kernel sang không gian người dùng. Đã xảy ra hai bản sao của bộ mô tả tệp
  • Mặc dù việc bỏ phiếu đã được tối ưu hóa từ việc bắt đầu bỏ phiếu trong không gian người dùng đến bắt đầu bỏ phiếu trong không gian kernel, nhưng select sẽ không cho luồng người dùng biết sự kiện sẵn sàng IO nào đã xảy ra. Nó chỉ đánh dấu các Ổ cắm sẵn sàng IO mà luồng người dùng vẫn cần duyệt. bộ sưu tập mô tả tệp để tìm Ổ cắm sẵn sàng cho IO cụ thể. Độ phức tạp về thời gian vẫn là O(n).

Trong hầu hết các trường hợp, các kết nối mạng không phải lúc nào cũng hoạt động. Nếu bạn chọn giám sát một số lượng lớn kết nối máy khách thì chỉ một số kết nối sẽ hoạt động. Tuy nhiên, phương pháp thăm dò sẽ trở nên hiệu quả hơn khi số lượng kết nối tăng lên. thấp hơn.

  • Hạt nhân sẽ sửa đổi bộ mô tả tệp gốc. Do đó, bộ mô tả tệp cần được đặt lại mỗi khi cuộc gọi chọn được bắt đầu lại trong không gian người dùng.
  • Bộ mô tả tệp của cấu trúc BitMap có độ dài cố định là 1024, do đó, nó chỉ có thể giám sát các bộ mô tả tệp từ 0 đến 1023.
  • Cuộc gọi hệ thống chọn không an toàn theo luồng.

Chi phí hoạt động gây ra bởi những thiếu sót trong lựa chọn ở trên sẽ tăng tuyến tính khi số lượng đồng thời tăng lên.

Rõ ràng lựa chọn không thể giải quyết được vấn đề C10K và chỉ phù hợp với các kịch bản có khoảng 1.000 kết nối đồng thời.

thăm dò

Poll tương đương với một phiên bản cải tiến của select, nhưng nguyên tắc hoạt động của nó về cơ bản không khác gì select.

số nguyên thăm dò(cấu trúc thăm dò ý kiến *fds, chưa ký số nguyên nfds, số nguyên thời gian chờ)
cấu trúc thăm dò ý kiến { số nguyên fd; /* mô tả tập tin */ ngắn sự kiện; /*Các sự kiện cần theo dõi */ ngắn tua lại; /* Sự kiện thực tế được sửa đổi và thiết lập bởi kernel */ };

Bộ mô tả tệp được sử dụng trong select là fd_set của cấu trúc BitMap với độ dài cố định là 1024 và cuộc thăm dò được thay thế bằng một mảng có cấu trúc pollfd không có độ dài cố định, do đó không có giới hạn về số lượng mô tả tối đa (của tất nhiên nó cũng sẽ phải tuân theo giới hạn của bộ mô tả tệp hệ thống).

Cuộc thăm dò chỉ cải thiện giới hạn mà select chỉ có thể nghe 1024 bộ mô tả tệp, nhưng nó không cải thiện hiệu suất. Về bản chất không có nhiều khác biệt so với select.

  • Cũng cần thăm dò bộ mô tả tệp được đặt trong không gian kernel và không gian người dùng. Độ phức tạp về thời gian của việc tìm Ổ cắm sẵn sàng cho IO vẫn là O(n).
  • Cũng cần phải sao chép toàn bộ bộ sưu tập chứa một số lượng lớn các bộ mô tả tệp qua lại giữa không gian người dùng và không gian kernel, bất kể các bộ mô tả tệp này đã sẵn sàng hay chưa. Chi phí hoạt động của chúng tăng tuyến tính khi số lượng bộ mô tả tệp tăng lên.
  • Select và Poll cần chuyển toàn bộ bộ socket mới vào kernel mỗi khi họ thêm hoặc xóa một socket cần giám sát.

Thăm dò ý kiến ​​cũng không phù hợp với các kịch bản có tính tương tranh cao. Vẫn không giải quyết được vấn đề C10K.

epoll

Thông qua phần giới thiệu ở trên về các nguyên tắc cốt lõi của lựa chọn và thăm dò ý kiến, chúng ta có thể thấy rằng những hạn chế về hiệu suất của việc chọn và thăm dò ý kiến ​​chủ yếu được phản ánh ở ba vị trí sau:

  • Bởi vì kernel không lưu bộ socket mà chúng ta muốn giám sát nên chúng ta cần chuyển vào và ra bộ mô tả tệp socket đầy đủ mỗi khi chúng ta gọi select và poll. Điều này dẫn đến một số lượng lớn các bộ mô tả tệp thường xuyên được sao chép qua lại giữa không gian người dùng và không gian kernel.
  • Vì kernel sẽ không thông báo các ổ cắm sẵn sàng IO cụ thể mà chỉ đánh dấu các ổ cắm sẵn sàng IO này, nên khi lệnh gọi hệ thống chọn trả về, vẫn cần phải duyệt qua hoàn toàn bộ mô tả tệp ổ cắm được đặt trong không gian người dùng để có được IO sẵn sàng cụ thể ổ cắm.
  • Trong không gian kernel, các socket sẵn sàng cho IO cũng có được thông qua truyền tải.

Chúng ta hãy xem epoll giải quyết những vấn đề này như thế nào. Trước khi giới thiệu các nguyên tắc cốt lõi của epoll, chúng ta cần giới thiệu một số kiến ​​thức cơ bản cốt lõi cần có để hiểu quy trình hoạt động của epoll.

Tạo ổ cắm

Chuỗi máy chủ bắt đầu chặn sau khi gọi lệnh gọi hệ thống chấp nhận. Khi máy khách kết nối và hoàn tất bắt tay ba chiều TCP, kernel sẽ tạo một Socket tương ứng làm giao diện kernel để liên lạc giữa máy chủ và máy khách.

Từ góc độ của nhân Linux, mọi thứ đều là một tệp và Sockets cũng không ngoại lệ. Khi kernel tạo một Socket, nó sẽ đưa Socket vào danh sách các tệp được mở bởi tiến trình hiện tại và quản lý nó.

Chúng ta hãy xem các cấu trúc dữ liệu kernel liên quan đến việc quản lý tiến trình của các danh sách file đang mở này. Sau khi hiểu rõ các cấu trúc dữ liệu này, chúng ta sẽ hiểu rõ hơn về vai trò của Socket trong kernel. Và sẽ rất hữu ích nếu chúng ta hiểu được quá trình tạo ra epoll sau này.

Quản lý trong quá trình cấu trúc danh sách tệp

Hãy nói về Netty và nhìn vào mô hình IO từ góc độ kernel

struct Taste_struct là cấu trúc dữ liệu được sử dụng trong kernel để thể hiện một tiến trình. Nó chứa tất cả thông tin về tiến trình. Trong phần này chúng tôi chỉ liệt kê các thuộc tính liên quan đến quản lý file.

Tất cả các file được mở trong quá trình này đều được tổ chức và quản lý thông qua một mảng fd_array. Chỉ số dưới của mảng là phần mô tả file mà chúng ta thường nhắc đến và file cấu trúc dữ liệu file tương ứng được lưu trữ trong mảng. Mỗi khi một tệp được mở, kernel sẽ tạo một tệp cấu trúc tương ứng với nó và tìm một vị trí trống trong fd_array để gán cho nó. Chỉ số dưới tương ứng trong mảng là bộ mô tả tệp mà chúng ta sử dụng trong không gian người dùng.

Theo mặc định, đối với bất kỳ quy trình nào, bộ mô tả tệp 0 đại diện cho đầu vào tiêu chuẩn stdin, bộ mô tả tệp 1 đại diện cho đầu ra tiêu chuẩn stdout và bộ mô tả tệp 2 đại diện cho đầu ra lỗi tiêu chuẩn stderr.

Danh sách các tệp được mở trong quá trình fd_array được xác định trong cấu trúc dữ liệu kernel struct files_struct. Có một con trỏ struct fd **fd trong cấu trúc struct fdtable trỏ đến fd_array.

Vì phần này thảo luận về cấu trúc dữ liệu của hệ thống mạng hạt nhân, nên ở đây chúng tôi lấy loại tệp Ổ cắm làm ví dụ:

Con trỏ dữ liệu riêng tư trong tệp cấu trúc cấu trúc dữ liệu hạt nhân được sử dụng để đóng gói siêu thông tin tệp trỏ đến cấu trúc Ổ cắm cụ thể.

Thuộc tính file_Operations trong tệp struct xác định hàm thao tác tệp. Các loại tệp khác nhau có các tệp_oper_action tương ứng khác nhau. Đối với các loại tệp Ổ cắm, tệp_hoạt động ở đây trỏ đến socket_file_ops.

Khi chúng ta khởi tạo các lệnh gọi hệ thống như đọc và ghi vào Socket trong không gian người dùng, thứ đầu tiên sẽ được gọi khi vào kernel là socket_file_ops được trỏ đến trong file struct tương ứng với Socket. Ví dụ: khi một thao tác ghi được bắt đầu trên Ổ cắm, thứ đầu tiên được gọi trong kernel là sock_write_iter được xác định trong socket_file_ops. Hoạt động đọc tương ứng được khởi tạo bởi Socket trong kernel là sock_read_iter.

tĩnh hằng số cấu trúc thao tác tập tin socket_file_ops = { .người sở hữu = MODULE NÀY, .tìm kiếm = no_llseek, .đọc_nó = sock_read_iter, .viết_iter = sock_write_iter, .thăm dò = sock_poll, .đã mở khóa_ioctl = vớ_ioctl, .mmap = vớ_mmap, .giải phóng = vớ_đóng, .đồng bộ = sock_fasync, .trang gửi = trang gửi vớ, .ghép nối_ghi = generic_splice_sendpage, .nối_đọc = sock_splice_read,};

Cấu trúc hạt nhân socket

Hãy nói về Netty và nhìn vào mô hình IO từ góc độ kernel


Khi chúng ta viết một chương trình mạng, trước tiên chúng ta sẽ tạo một Ổ cắm, sau đó liên kết và lắng nghe dựa trên Ổ cắm này. Trước tiên, chúng ta gọi đây là Ổ cắm nghe.

Khi chúng tôi gọi chấp nhận, kernel sẽ tạo một Ổ cắm mới dựa trên Ổ cắm nghe dành riêng cho giao tiếp mạng với máy khách. Và gán tập hợp hàm vận hành Ổ cắm (inet_stream_ops) op trong Ổ cắm nghe cho thuộc tính ops của Ổ cắm mới.

hằng số cấu trúc proto_ops inet_stream_ops = { .ràng buộc = inet_bind, .kết nối = inet_stream_kết_nối, .chấp nhận = inet_chấp nhận, .thăm dò = tcp_thăm dò, .Nghe = inet_nghe, .gửi tin nhắn = inet_gửitinnhắn, .nhận tin nhắn = inet_recvmsg, ......}

Điều cần lưu ý ở đây là Ổ cắm nghe và Ổ cắm thực sự được sử dụng để liên lạc mạng là hai Ổ cắm, một được gọi là Ổ cắm nghe và Ổ cắm còn lại được gọi là Ổ cắm được kết nối.

Sau đó kernel sẽ tạo và khởi tạo file struct cho Socket được kết nối, đồng thời gán bộ hàm thao tác file Socket (socket_file_ops) cho con trỏ f_ops trong file struct. Sau đó trỏ con trỏ tệp trong ổ cắm cấu trúc tới cấu trúc tệp cấu trúc mới được phân bổ.

Kernel duy trì hai hàng đợi:

  • Một là hàng đợi kết nối đã hoàn thành quá trình bắt tay ba chiều TCP và trạng thái kết nối được thiết lập. Trong kernel là icsk_accept_queue.
  • Một là quá trình bắt tay ba chiều TCP chưa được hoàn thành và trạng thái kết nối nằm trong hàng đợi bán kết nối của syn_rcvd.

Sau đó gọi socket->ops->accept. Từ sơ đồ cấu trúc hạt nhân của Socket, chúng ta có thể thấy rằng inet_accept thực sự được gọi. Hàm này sẽ kiểm tra xem có kết nối nào được thiết lập trong icsk_accept_queue hay không. Nếu có, hãy trực tiếp từ icsk_accept_queue. vớ cấu trúc. Và gán đối tượng struct sock này cho con trỏ sock trong struct socket.

Struct sock là một đối tượng kernel rất cốt lõi trong struct socket. Ở đây xác định hàng đợi nhận, hàng đợi gửi, hàng chờ, con trỏ hàm gọi lại sẵn sàng cho dữ liệu và giao thức kernel được đề cập trong phần giới thiệu về quá trình nhận và gửi các gói mạng. . Tập hợp các chức năng hoạt động ngăn xếp.

  • Theo tham số giao thức trong lệnh gọi hệ thống sock_create được khởi tạo khi tạo Ổ cắm (đối với giao thức TCP, giá trị tham số ở đây là SOCK_STREAM), các tập hợp triển khai phương thức hoạt động được xác định cho tcp được tìm thấy là inet_stream_ops và tcp_prot. Và đặt chúng lần lượt là socket->ops và sock->sk_prot.

Tại đây bạn có thể xem lại “Sơ đồ cấu trúc hạt nhân ổ cắm” ở đầu phần này để hiểu mối quan hệ giữa chúng.

Giao diện hoạt động liên quan đến socket được xác định trong tập hợp hàm inet_stream_ops, chịu trách nhiệm cung cấp giao diện cho người dùng. Giao diện hoạt động giữa socket và ngăn xếp giao thức hạt nhân được xác định trên con trỏ sk_prot trong struct sock, con trỏ này trỏ đến tập hợp hàm vận hành giao thức tcp_prot.

cấu trúc Vì vậy tcp_prot = { .tên = "TCP", .người sở hữu = MODULE NÀY, .đóng = tcp_đóng, .kết nối = tcp_v4_kết nối, .ngắt kết nối = tcp_ngắt_kết_nối, .chấp nhận = inet_csk_chấp nhận, .giữ sống = tcp_set_keepalive, .nhận tin nhắn = tcp_recvmsg, .gửi tin nhắn = tcp_gửitinnhắn, .backlog_rcv = tcp_v4_do_rcv, ......}

Lệnh gọi IO hệ thống được khởi tạo tới Ổ cắm được đề cập trước đó sẽ gọi thao tác tệp file_activity được đặt trong tệp cấu trúc cấu trúc tệp của Ổ cắm trong kernel, sau đó gọi hàm thao tác inet_stream_opssocket được chỉ ra bởi ops trong ổ cắm cấu trúc và cuối cùng gọi nó vào ổ cắm cấu trúc. struct sock. Bộ sưu tập giao diện chức năng ngăn xếp giao thức hạt nhân tcp_prot được trỏ đến bởi con trỏ sk_prot.

Hãy nói về Netty và nhìn vào mô hình IO từ góc độ kernel

  • Đặt con trỏ hàm sk_data_ready trong đối tượng struct sock thành sock_def_readable. Kernel sẽ gọi lại hàm này khi dữ liệu Socket đã sẵn sàng.
  • Hàng đợi trong struct sock lưu trữ tiến trình fd nơi lệnh gọi IO của hệ thống bị chặn và chức năng gọi lại tương ứng. Hãy nhớ địa điểm này, chúng tôi sẽ đề cập đến nó sau khi giới thiệu epoll!

Sau khi các đối tượng lõi lõi như tệp struct, struct socket và struct sock được tạo, bước cuối cùng là đặt tệp struct tương ứng với đối tượng socket vào danh sách tệp fd_array được mở bởi tiến trình. Sau đó, hệ thống gọi chấp nhận và trả về bộ mô tả tệp fd của ổ cắm cho chương trình người dùng.

Nguyên tắc chặn tiến trình người dùng và đánh thức trong việc chặn IO

Khi chúng tôi giới thiệu việc chặn IO ở phần trước, chúng tôi đã đề cập rằng khi một tiến trình người dùng bắt đầu một lệnh gọi IO hệ thống, ở đây chúng tôi lấy read làm ví dụ. Quá trình người dùng sẽ kiểm tra trạng thái kernel xem dữ liệu đã đến bộ đệm nhận Socket tương ứng hay chưa. .

  • Nếu có dữ liệu trong bộ đệm nhận Socket, dữ liệu sẽ được sao chép vào không gian người dùng và lệnh gọi hệ thống sẽ quay trở lại.
  • Nếu không có dữ liệu trong bộ đệm nhận Socket, tiến trình người dùng sẽ từ bỏ CPU và chuyển sang trạng thái chặn. Khi dữ liệu đến bộ đệm nhận, tiến trình người dùng sẽ được đánh thức, chuyển sang trạng thái sẵn sàng từ trạng thái chặn. và chờ lập kế hoạch của CPU.

Trong phần này, chúng ta sẽ xem xét quá trình người dùng bị chặn trên Ổ cắm như thế nào và nó được đánh thức như thế nào trên Ổ cắm. Điều quan trọng là phải hiểu quy trình này và việc hiểu quy trình thông báo sự kiện của epoll sẽ rất hữu ích cho chúng tôi.

  • Đầu tiên, khi chúng ta thực hiện lệnh gọi hệ thống đọc tới Socket trong tiến trình người dùng, tiến trình người dùng sẽ chuyển từ chế độ người dùng sang chế độ kernel.
  • Tìm fd_array trong cấu trúc struct task_struct của tiến trình, tìm file struct tương ứng theo bộ mô tả file socket fd, gọi hàm thao tác file trong file struct kết hợp với file_Operation và lệnh gọi hệ thống đọc tương ứng với sock_read_iter.
  • Tìm ổ cắm cấu trúc được trỏ đến bởi tệp cấu trúc trong hàm sock_read_iter và gọi socket->ops->recvmsg. Ở đây chúng ta biết rằng lệnh gọi là inet_recvmsg được xác định trong bộ sưu tập inet_stream_ops.
  • Bạn sẽ tìm thấy struct sock trong inet_recvmsg và gọi sock->skprot->recvmsg. Cái được gọi ở đây là hàm tcp_recvmsg được xác định trong bộ sưu tập tcp_prot.

Để biết toàn bộ quá trình gọi, vui lòng tham khảo "Biểu đồ cấu trúc cuộc gọi IO hệ thống" ở trên.

Sau khi làm quen với ngăn xếp lệnh gọi hàm kernel, chúng ta hãy xem cách lệnh gọi IO của hệ thống chặn tiến trình người dùng trong hàm kernel tcp_recvmsg.

Hãy nói về Netty và nhìn vào mô hình IO từ góc độ kernel


số nguyên tcp_recvmsg(cấu trúc kiocb *công việc, cấu trúc tất *trượt tuyết, cấu trúc tinnhắn *tin nhắn, kích thước_t chỉ một, số nguyên không chặn, số nguyên cờ, số nguyên *địa chỉ_len){ ................Bỏ qua mã không cốt lõi............... // Truy cập hàng đợi nhận được xác định trong đối tượng sock hàng đợi đi bộ(&trượt tuyết->hàng đợi nhận hàng, skb) { ................Bỏ qua mã không cốt lõi............... //Không nhận đủ dữ liệu, gọi sk_wait_data để chặn tiến trình hiện tại dữ liệu chờ đợi(trượt tuyết, &tôi sợ);}
số nguyên dữ liệu chờ đợi(cấu trúc tất *trượt tuyết, dài *tôi sợ){ //Tạo phần tử wait_queue_t trên hàng chờ trong struct sock // Liên kết bộ mô tả quy trình và hàm gọi lại autoremove_wake_function với wait_queue_t ĐỊNH NGHĨA_CHỜ(Chờ đợi); // Gọi sk_sleep để lấy con trỏ head của hàng đợi bên dưới đối tượng sock wait_queue_head_t //Gọi prepare_to_wait để chèn mục chờ mới tạo wait_queue_t vào hàng chờ và đặt trạng thái quá trình thành có thể ngắt INTERRUPTIBLE chuẩn bị_chờ_đợi(ngủ(trượt tuyết), &Chờ đợi, NHIỆM VỤ_BẮT_BUỘC); thiết lập_bit(SOCK_ASYNC_WAITDATA, &trượt tuyết->ổ cắm sk->cờ); // Từ bỏ CPU bằng cách gọi Schedule_timeout, rồi ngủ, dẫn đến chuyển đổi ngữ cảnh rc = sự kiện sk_wait(trượt tuyết, tôi sợ, !hàng đợi skb_trống(&trượt tuyết->hàng đợi nhận hàng)); ...
  • Đầu tiên, kiểu chờ wait_queue_t trên hàng đợi trong struct sock sẽ được tạo trong DEFINE_WAIT.
#định nghĩa ĐỊNH NGHĨA_CHỜ(tên) ĐỊNH NGHĨA CHỜ_CHỨC NĂNG(tên, chức năng tự động xóa_đánh_thức) #định nghĩa ĐỊNH NGHĨA CHỜ_CHỨC NĂNG(tên, chức năng) \ hàng đợi_t tên = { \ .riêng tư = hiện hành, \ .chức năng = chức năng, \ .danh sách nhiệm vụ = DANH SÁCH_HEAD_INIT((tên).danh sách nhiệm vụ), \ }

Riêng tư trong kiểu chờ Wait_queue_t được sử dụng để liên kết quá trình người dùng fd bị chặn trên ổ cắm hiện tại. func được sử dụng để liên kết chức năng gọi lại đã đăng ký trên mục đang chờ. Đã đăng ký ở đây là autoremove_wake_function.

  • Gọi sk_sleep(sk) để lấy con trỏ đầu hàng chờ wait_queue_head_t trong đối tượng struct sock.
  • Gọi prepare_to_wait để chèn mục chờ mới được tạo wait_queue_t vào hàng đợi và đặt quy trình thành ngắt INTERRUPTIBL.
  • Gọi sk_wait_event để từ bỏ CPU và tiến trình chuyển sang trạng thái ngủ.

Chúng ta đã giới thiệu xong quy trình chặn của quy trình người dùng. Điều quan trọng là phải hiểu và ghi nhớ cấu trúc của kiểu chờ wait_queue_t trên hàng đợi được xác định trong struct sock. Chúng tôi cũng sẽ sử dụng nó trong phần giới thiệu epoll sau này.

Tiếp theo, chúng tôi sẽ giới thiệu cách đánh thức quy trình người dùng khi dữ liệu sẵn sàng.

Trong phần giới thiệu "Quy trình nhận gói mạng" ở đầu bài viết này, chúng tôi đã đề cập:

  • Khi gói dữ liệu mạng đến card mạng, card mạng sẽ đưa dữ liệu vào RingBuffer thông qua DMA.
  • Sau đó, bắt đầu ngắt cứng tới CPU, tạo sk_buffer trong chương trình phản hồi ngắt cứng và sao chép dữ liệu mạng vào sk_buffer.
  • Sau đó, một ngắt mềm được bắt đầu và luồng nhân ksoftirqd phản hồi với ngắt mềm đó và gọi hàm thăm dò để gửi sk_buffer đến ngăn xếp giao thức hạt nhân để xử lý giao thức từng lớp.
  • Trong hàm tcp_rcv của lớp vận chuyển, hãy xóa tiêu đề TCP và tìm Ổ cắm tương ứng dựa trên bốn bộ dữ liệu (IP nguồn, cổng nguồn, IP đích, cổng đích).
  • Cuối cùng, đặt sk_buffer vào hàng đợi nhận trong Socket.

Các quy trình trên là quá trình hoàn chỉnh của kernel nhận dữ liệu mạng. Chúng ta hãy xem quy trình người dùng được đánh thức như thế nào sau khi nhận được gói dữ liệu.

Hãy nói về Netty và nhìn vào mô hình IO từ góc độ kernel

  • Khi ngắt mềm đặt sk_buffer vào hàng đợi nhận của Ổ cắm, con trỏ gọi lại hàm sẵn sàng dữ liệu sk_data_ready sẽ được gọi. Như chúng tôi đã đề cập trước đó, con trỏ hàm này trỏ đến hàm sock_def_readable trong quá trình khởi tạo.
  • Trong hàm sock_def_readable, hàng đợi chờ socket->sock->sk_wq sẽ được lấy. Trong hàm Wake_up_common, tìm mục chờ wait_queue_t từ hàng đợi sk_wq, gọi lại hàm callback func (wait_queue_t->func) đã đăng ký trên mục chờ, tạo mục chờ wait_queue_t như chúng tôi đã đề cập, hàm gọi lại được đăng ký ở đây là autoremove_wake_function .

Ngay cả khi nhiều tiến trình bị chặn trên cùng một ổ cắm, chỉ một tiến trình sẽ được đánh thức. Mục đích của nó là để tránh các cuộc tấn công hoảng loạn.

  • Trong hàm autoremove_wake_function, try_to_wake_up được gọi dựa trên tiến trình bị chặn fd được liên kết với riêng tư trên mục chờ Wait_queue_t để đánh thức tiến trình bị chặn trên Ổ cắm.

Hãy nhớ con trỏ hàm func trong wait_queue_t, trong đó hàm gọi lại epoll sẽ được đăng ký trong epoll.

Bây giờ chúng ta đã giới thiệu những kiến ​​thức cơ bản cần thiết để hiểu về epoll, sau khi nói nhiều, cuối cùng chúng ta cũng chính thức bước vào chủ đề của phần này, epoll.

epoll_create tạo một đối tượng epoll

epoll_create là một lệnh gọi hệ thống do kernel cung cấp để tạo các đối tượng epoll cho chúng ta. Khi chúng ta gọi epoll_create trong tiến trình người dùng, kernel sẽ tạo một đối tượng struct eventpoll cho chúng ta và cũng có một file struct tương ứng được liên kết với nó. cần thêm cuộc thăm dò sự kiện struct này Tệp cấu trúc được liên kết với đối tượng được quản lý trong danh sách tệp fd_array được mở bởi quy trình.

Khi bạn đã quen với logic tạo của Socket thì logic tạo của epoll không có gì khó hiểu.

Con trỏ file_Operation trong tệp cấu trúc được liên kết với đối tượng struct Eventpoll trỏ đến tập hợp hàm hoạt động Eventpoll_fops.

tĩnh hằng số cấu trúc thao tác tập tin sự kiện thăm dò ý kiến_fops = { .giải phóng = ep_eventpoll_phát hành; .thăm dò = thăm dò sự kiện ep_,}

Hãy nói về Netty và nhìn vào mô hình IO từ góc độ kernel


cấu trúc cuộc thăm dò sự kiện { // Hàng đợi, các tiến trình bị chặn trên epoll sẽ được đặt ở đây hàng đợi_đầu_t thế nào; // Hàng đợi sẵn sàng, các kết nối ổ cắm sẵn sàng IO sẽ được đặt ở đây cấu trúc danh sách_đầu từ rdll; // Cây đỏ đen được sử dụng để quản lý tất cả các kết nối socket nghe cấu trúc rb_gốc rbr; ......}
  • wait_queue_head_t wq:Hàng đợi trong epoll lưu trữ các tiến trình của người dùng bị chặn trên epoll. Khi IO sẵn sàng, epoll có thể tìm thấy các tiến trình bị chặn này thông qua hàng đợi này và đánh thức chúng, từ đó thực hiện các lệnh gọi IO để đọc và ghi dữ liệu trên Socket.

Lưu ý ở đây rằng nó khác với hàng đợi trong Socket!!.

  • Cấu trúc list_head rdllist:Hàng đợi sẵn sàng trong epoll lưu trữ tất cả các Ổ cắm sẵn sàng cho IO. Quá trình người dùng được đánh thức có thể đọc trực tiếp hàng đợi này để nhận các Ổ cắm hoạt động IO. Không cần phải lặp lại toàn bộ bộ sưu tập Ổ cắm.

Đây là nơi epoll hiệu quả hơn so với chọn và thăm dò ý kiến. Chọn và thăm dò ý kiến ​​​​trả về tất cả các kết nối ổ cắm. Chúng ta cần duyệt lại trong không gian người dùng để tìm các kết nối Ổ cắm hoạt động IO thực sự. Và epoll chỉ trả về kết nối Ổ cắm với IO đang hoạt động. Quá trình người dùng có thể trực tiếp thực hiện các hoạt động IO.

  • cấu trúc rb_root rbr : Vì cây đỏ đen là cây tốt nhất xét về hiệu suất toàn diện như tìm kiếm, chèn và xóa nên epoll sử dụng cây đỏ đen bên trong để quản lý một số lượng lớn kết nối Ổ cắm.

Chọn sử dụng mảng để quản lý kết nối và thăm dò ý kiến ​​​​sử dụng danh sách được liên kết để quản lý kết nối.

epoll_ctl thêm Ổ cắm nghe vào đối tượng epoll

Khi chúng ta gọi epoll_create để tạo đối tượng epoll struct eventpoll trong kernel, chúng ta có thể sử dụng epoll_ctl để thêm các kết nối Socket mà chúng ta cần quản lý vào epoll.

Đầu tiên, một bản tóm tắt cấu trúc dữ liệu đại diện cho một kết nối Ổ cắm phải được tạo trong nhân epoll. Để xem xét hiệu suất tổng thể, một cây đỏ-đen được sử dụng trong epoll để quản lý các kết nối ổ cắm lớn này. Vì vậy, cấu trúc epitem là một nút cây màu đỏ đen.

cấu trúc epitem.png 。

cấu trúc biệt hiệu { //Trỏ tới đối tượng epoll đang sở hữu cấu trúc cuộc thăm dò sự kiện *tập; // Các sự kiện quan tâm đã đăng ký, nghĩa là epoll_event trong không gian người dùng  cấu trúc sự kiện epoll sự kiện; //Trỏ tới hàng đợi sẵn sàng trong đối tượng epoll cấu trúc danh sách_đầu liên kết; //Trỏ tới nút cây đỏ đen tương ứng trong epoll cấu trúc nút rb rbn; //Trỏ tới cấu trúc tệp socket->được biểu thị bằng epiitem và fd tương ứng cấu trúc epoll_filefd ffd; }

Chìa khóa ở đây là ghi nhớ các thành viên rdllink và epoll_filefd trong cấu trúc epitem struct mà chúng ta sẽ sử dụng sau này.

Sau khi tạo cấu trúc dữ liệu struct epitem biểu thị kết nối Socket trong kernel, chúng ta cần tạo mục chờ wait_queue_t trên hàng chờ trong Socket và đăng ký hàm gọi lại epoll ep_poll_callback.

Qua điềm báo của phần “Nguyên tắc chặn và đánh thức tiến trình người dùng trong IO bị chặn”, tôi nghĩ mọi người đã đoán được ý nghĩa của bước này! Khi đó, chức năng gọi lại autoremove_wake_function đã được đăng ký trong mục chờ wait_queue_t. Nhớ?

Hàm gọi lại ep_poll_callback của epoll là cốt lõi của cơ chế thông báo sự kiện IO đồng bộ của epoll và đây cũng là điểm khác biệt cơ bản về hiệu suất giữa thăm dò ý kiến ​​và chọn lọc, áp dụng phương pháp thăm dò hạt nhân.

Hãy nói về Netty và nhìn vào mô hình IO từ góc độ kernel

Ở đây có cấu trúc dữ liệu mới eppoll_entry. Chức năng của nó là gì? Bạn có thể đoán chức năng của nó dựa vào hình trên.

Chúng ta biết rằng loại trong hàng đợi socket->sock->sk_wq là wait_queue_t. Chúng ta cần đăng ký hàm gọi lại epoll ep_poll_callback trên hàng đợi của socket được biểu thị bằng struct epitem.

Bằng cách này, khi dữ liệu đến hàng đợi nhận trong socket, kernel sẽ gọi lại sk_data_ready. Trong phần về nguyên tắc chặn tiến trình người dùng và đánh thức trong chặn IO, chúng ta biết rằng con trỏ hàm sk_data_ready sẽ trỏ đến sk_def_readable và hàm gọi lại sẽ được đăng ký trong sk_def_readable để chờ. Bạn cần tìm epitem trong ep_poll_callback và đặt epitem sẵn sàng cho IO vào hàng đợi sẵn sàng trong epoll.

Loại hàng đợi socket là wait_queue_t và không thể liên kết với epiitem. Vậy là cấu trúc struct eppoll_entry xuất hiện, chức năng của nó là liên kết các item chờ wait_queue_t và epiitem trong hàng chờ của Socket.

cấu trúc eppoll_entry { //Trỏ tới epiitem liên quan cấu trúc biệt hiệu *căn cứ; // Liên kết các mục đang chờ trong hàng chờ trong socket nghe (private = null func = ep_poll_callback) hàng đợi_t Chờ đợi; //Lắng nghe con trỏ đầu hàng chờ trong socket hàng đợi_đầu_t *đầu; ......... }; 

Bằng cách này, trong hàm gọi lại ep_poll_callback, bạn có thể tìm thấy eppoll_entry thông qua macro container_of dựa trên mục chờ đợi trong hàng đợi chờ của Ổ cắm, sau đó tìm epiitem.

container_of là một macro thường được sử dụng trong nhân Linux. Nó được sử dụng để lấy con trỏ của chính cấu trúc từ con trỏ có trong một cấu trúc nhất định, đó là lấy toàn bộ biến cấu trúc thông qua địa chỉ đầu tiên của một thành viên. trong biến cấu trúc.

Cần lưu ý ở đây rằng cài đặt riêng tư trong wait_queue_t lần này là null, vì Ổ cắm ở đây được quản lý bởi epoll và quá trình bị chặn trên Ổ cắm cũng được đánh thức bởi epoll. Func được đăng ký trong mục chờ wait_queue_t là ep_poll_callback thay vì autoremove_wake_function. Quá trình chặn không cần autoremove_wake_function để đánh thức, vì vậy ở đây, chế độ riêng tư được đặt thành null.

Khi mục chờ wait_queue_t được tạo trong hàng chờ của Ổ cắm và chức năng gọi lại epoll ep_poll_callback được đăng ký thì epiitem sẽ được liên kết thông qua eppoll_entry. Tất cả những gì còn lại phải làm là chèn epiitem vào cấu trúc cây đỏ đen rb_root rbr trong epoll.

Ở đây bạn có thể thấy một sự tối ưu hóa khác của epoll quản lý tập trung tất cả các kết nối socket thông qua cây đỏ-đen trong kernel. Mỗi khi một kết nối socket được thêm hoặc xóa, đó là một sự bổ sung và xóa tăng dần, thay vì chuyển toàn bộ các kết nối socket vào kernel mỗi khi nó được gọi như chọn và thăm dò ý kiến. Tránh sao chép bộ nhớ thường xuyên và số lượng lớn.

epoll_wait đồng bộ hóa việc chặn để có được Ổ cắm sẵn sàng cho IO

Sau khi chương trình người dùng gọi epoll_wait, trước tiên kernel sẽ kiểm tra xem hàng đợi sẵn sàng eventpoll->rdllist trong epoll có một epiitem sẵn sàng cho IO hay không. Epitem đóng gói thông tin ổ cắm. Nếu có một epiitem sẵn sàng trong hàng đợi sẵn sàng, thông tin ổ cắm sẵn sàng sẽ được gói gọn trong epoll_event và được trả về.

Nếu không có epiitem sẵn sàng cho IO trong hàng đợi sẵn sàng cho sự kiện->dlllist, mục chờ wait_queue_t sẽ được tạo, fd của quy trình người dùng sẽ được liên kết với wait_queue_t->private và chức năng gọi lại default_wake_function sẽ được đăng ký trên mục chờ wait_queue_t->func. Cuối cùng, các mục đang chờ sẽ được thêm vào hàng đợi trong epoll. Quá trình người dùng từ bỏ CPU và chuyển sang trạng thái chặn.

Hãy nói về Netty và nhìn vào mô hình IO từ góc độ kernel

Nguyên tắc chặn ở đây giống như trong mô hình IO chặn, ngoại trừ trong mô hình IO chặn, autoremove_wake_function được đăng ký cho mục chờ wait_queue_t->func và mục chờ được thêm vào hàng đợi trong ổ cắm. Những gì được đăng ký ở đây là default_wake_function, bổ sung các mục đang chờ vào hàng đợi trong epoll.

Hãy nói về Netty và nhìn vào mô hình IO từ góc độ kernel

Sau khi đã tìm hiểu rất nhiều kiến ​​thức trước đây, cuối cùng đây là toàn bộ quy trình làm việc của epoll:

Hãy nói về Netty và nhìn vào mô hình IO từ góc độ kernel

  • Khi gói dữ liệu mạng đến bộ đệm nhận của ổ cắm sau khi được xử lý bởi ngăn xếp giao thức hạt nhân trong ngắt mềm, con trỏ gọi lại sẵn sàng cho dữ liệu của ổ cắm sk_data_ready sẽ được gọi và chức năng gọi lại là sock_def_readable. Tìm mục đang chờ trong hàng đợi của ổ cắm, trong đó hàm gọi lại được đăng ký trong mục chờ là ep_poll_callback.
  • Trong hàm gọi lại ep_poll_callback, theo struct wait_queue_t chờ trong struct eppoll_entry, đối tượng eppoll_entry được tìm thấy thông qua macro container_of và biểu tượng cấu trúc dữ liệu đóng gói ổ cắm được tìm thấy thông qua con trỏ cơ sở của nó và nó được thêm vào sẵn sàng danh sách xếp hàng trong epoll.
  • Sau đó kiểm tra xem có các mục đang chờ trong hàng chờ trong epoll hay không, tức là kiểm tra xem có quá trình nào bị chặn trên epoll_wait đang chờ ổ cắm IO sẵn sàng hay không. Nếu không có mục chờ, quá trình xử lý softirq hoàn tất.
  • Nếu có một mục đang chờ, hãy quay lại hàm gọi lại default_wake_function đã đăng ký trong mục chờ, đánh thức quá trình chặn trong hàm gọi lại và đóng gói thông tin ổ cắm sẵn sàng IO của epiitem trong danh sách hàng đợi sẵn sàng vào struct epoll_event và trả về .
  • Quá trình người dùng lấy epoll_event để lấy ổ cắm sẵn sàng IO và bắt đầu lệnh gọi IO hệ thống để đọc dữ liệu.

Hãy nói lại về kích hoạt ngang và kích hoạt cạnh

Có rất nhiều lời giải thích về hai chế độ này trên Internet, hầu hết đều tương đối mơ hồ, có cảm giác như chúng chỉ là những mô tả mang tính khái niệm gượng ép, sau khi đọc thì rất khó hiểu. Vì vậy ở đây tác giả xin kết hợp quy trình làm việc của epoll ở trên để đưa ra cách hiểu riêng của mình về hai chế độ này và cố gắng giải thích rõ ràng những điểm tương đồng và khác biệt giữa hai chế độ làm việc này.

Sau khi giải thích chi tiết về quy trình làm việc của epoll ở trên, chúng ta biết rằng khi dữ liệu đến socket mà chúng ta đang theo dõi, ngắt mềm sẽ thực thi hàm callback ep_poll_callback của epoll. Trong hàm callback, epitem cấu trúc dữ liệu mô tả thông tin socket. trong epoll sẽ được chèn vào hàm gọi lại. Vào danh sách thứ tự hàng đợi sẵn sàng trong epoll. Sau đó, quy trình người dùng được đánh thức khỏi hàng chờ của epoll, epoll_wait trả lại ổ cắm sẵn sàng IO cho quy trình người dùng và sau đó epoll_wait xóa rdlllist.

Sự khác biệt quan trọng nhất giữa kích hoạt ngang và kích hoạt cạnh là khi vẫn còn dữ liệu có thể đọc được trong bộ đệm nhận trong ổ cắm. Liệu epoll_wait có xóa rdllist hay không.

  • Kích hoạt ngang:Ở chế độ này, sau khi luồng người dùng gọi epoll_wait để lấy ổ cắm sẵn sàng cho IO, nó sẽ thực hiện lệnh gọi IO hệ thống tới ổ cắm để đọc dữ liệu. Giả sử rằng chỉ một phần dữ liệu trong ổ cắm đã được đọc chứ không phải tất cả. lần này, epoll_wait được gọi lại và epoll_wait sẽ Kiểm tra xem bộ đệm nhận trong các Ổ cắm này có còn dữ liệu có thể đọc được hay không. Nếu vẫn còn dữ liệu có thể đọc được, hãy đặt lại ổ cắm vào danh sách thứ tự. Do đó, khi IO trên socket chưa được xử lý, việc gọi lại epoll_wait vẫn có thể lấy được các socket này và quy trình người dùng có thể tiếp tục xử lý các sự kiện IO trên socket.
  • Kích hoạt cạnh: Trong chế độ này, epoll_wait sẽ trực tiếp xóa danh sách thứ tự, bất kể dữ liệu có còn đọc được trên ổ cắm hay không. Vì vậy, ở chế độ kích hoạt cạnh, khi bạn không có thời gian để xử lý dữ liệu có thể đọc còn lại trong bộ đệm nhận ổ cắm, hãy gọi lại epoll_wait, vì danh sách thứ đã bị xóa tại thời điểm này và ổ cắm sẽ không quay trở lại từ epoll_wait nữa, vì vậy người dùng quá trình Ổ cắm này sẽ không được lấy lại và nó sẽ không thể thực hiện xử lý IO trên đó. Trừ khi dữ liệu IO mới đến trên ổ cắm này, theo quy trình làm việc của epoll, ổ cắm sẽ được đưa lại vào danh sách thứ tự.

Nếu bạn xử lý một phần dữ liệu trên ổ cắm ở chế độ kích hoạt cạnh, thì nếu muốn xử lý phần dữ liệu còn lại, bạn phải đợi cho đến khi dữ liệu mạng đến lại ổ cắm.

EpollSocketChannel được triển khai trong Netty mặc định ở chế độ kích hoạt cạnh. NIO của JDK mặc định ở chế độ kích hoạt ngang.

Tổng hợp tối ưu hóa lựa chọn và thăm dò ý kiến ​​​​của epoll

  • epoll quản lý các kết nối lớn thông qua các cây đỏ đen trong kernel, vì vậy khi gọi epoll_wait để có được một socket sẵn sàng cho IO, không cần phải chuyển vào bộ mô tả tệp socket nghe. Điều này tránh việc sao chép bộ mô tả tệp lớn qua lại giữa không gian người dùng và không gian kernel.

Select và Poll cần phải chuyển toàn bộ bộ mô tả tệp mỗi khi chúng được gọi, dẫn đến một số lượng lớn các thao tác sao chép thường xuyên.

  • epoll sẽ chỉ thông báo cho các ổ cắm sẵn sàng cho IO. Tránh chi phí truyền tải trong không gian người dùng.

Chọn và thăm dò ý kiến ​​sẽ chỉ đánh dấu các ổ cắm sẵn sàng cho IO và vẫn trả về toàn bộ số tiền, vì vậy trong không gian người dùng, chương trình người dùng vẫn cần duyệt qua toàn bộ bộ sưu tập cùng một lúc để tìm các ổ cắm sẵn sàng cho IO cụ thể.

  • epoll thông báo cho chương trình người dùng về ổ cắm sẵn sàng IO bằng cách đăng ký hàm gọi lại ep_poll_callback trên hàng đợi của ổ cắm. Tránh được chi phí bỏ phiếu trong kernel.

Trong hầu hết các trường hợp, ổ cắm không phải lúc nào cũng hoạt động IO khi đối mặt với các kết nối lớn, việc chọn và thăm dò ý kiến ​​​​sử dụng thăm dò hạt nhân để có được ổ cắm hoạt động IO, đây chắc chắn là lý do cốt lõi dẫn đến hiệu suất thấp.

Dựa trên những ưu điểm về hiệu suất nêu trên của epoll, cho đến nay, đây là mô hình IO mạng được tất cả các khung mạng chính và phần mềm trung gian proxy ngược sử dụng.

Vấn đề C10K có thể được giải quyết dễ dàng bằng mô hình IO ghép kênh epoll.

Giải pháp C100k vẫn dựa trên giải pháp C10K, sử dụng epoll để hợp tác với nhóm luồng, cộng với các cải tiến về hiệu suất và dung lượng của CPU, bộ nhớ và giao diện mạng. Trong hầu hết các trường hợp, C100K có thể đạt được một cách tự nhiên.

Ngay cả giải pháp C1000K về cơ bản cũng được xây dựng trên mô hình I/O đa kênh của epoll. Tuy nhiên, ngoài mô hình I/O, nó còn yêu cầu tối ưu hóa chuyên sâu ở mọi cấp độ từ ứng dụng đến nhân Linux, đến CPU, bộ nhớ và mạng. Đặc biệt, cần có phần cứng để giảm tải một số lượng lớn chức năng. ban đầu được xử lý thông qua phần mềm (Loại bỏ nhiều chi phí phản hồi ngắt và chi phí xử lý ngăn xếp giao thức hạt nhân).

IO điều khiển bằng tín hiệu

Hãy nói về Netty và nhìn vào mô hình IO từ góc độ kernel


Mọi người chắc chắn đã quen thuộc với thiết bị này khi chúng tôi đến một số khu ẩm thực để ăn, sau khi gọi món và thanh toán, ông chủ sẽ ra hiệu cho chúng tôi. Sau đó chúng ta có thể đến bàn ăn hoặc làm những việc khác với thiết bị tín hiệu này. Khi đèn báo sáng lên có nghĩa là bữa ăn đã sẵn sàng và chúng ta có thể ra cửa sổ lấy bữa.

Kịch bản cuộc sống điển hình này rất giống với mô hình IO điều khiển bằng tín hiệu mà chúng tôi sắp giới thiệu.

Theo mô hình IO điều khiển bằng tín hiệu, quy trình người dùng bắt đầu yêu cầu IO bằng cách gọi hàm sigaction thông qua hệ thống và đăng ký gọi lại tín hiệu trong ổ cắm tương ứng. Tại thời điểm này, quy trình người dùng không bị chặn và quy trình sẽ tiếp tục. công việc. Khi dữ liệu kernel đã sẵn sàng, kernel sẽ tạo tín hiệu SIGIO cho tiến trình và thông báo cho tiến trình đó thông qua lệnh gọi lại tín hiệu để thực hiện các thao tác IO liên quan.

Điều cần lưu ý ở đây là mô hình IO điều khiển bằng tín hiệu vẫn là IO đồng bộ, vì tuy không thể chặn trong khi chờ dữ liệu nhưng sẽ không thăm dò thường xuyên, nhưng khi dữ liệu đã sẵn sàng và tín hiệu kernel được thông báo, quá trình người dùng vẫn phải tự đọc dữ liệu và tình trạng tắc nghẽn xảy ra trong giai đoạn sao chép dữ liệu.

So với ba mô hình IO đầu tiên, mô hình IO điều khiển bằng tín hiệu nhận thấy rằng khi chờ dữ liệu sẵn sàng, quy trình không bị chặn và vòng lặp chính có thể tiếp tục hoạt động nên về mặt lý thuyết hiệu suất sẽ tốt hơn.

Nhưng trên thực tế, khi sử dụng giao tiếp giao thức TCP, mô hình IO điều khiển bằng tín hiệu hầu như không bao giờ được sử dụng. Lý do như sau:

  • Tín hiệu IO có thể không được thông báo do tràn hàng đợi tín hiệu khi thực hiện một số lượng lớn thao tác IO.
  • Tín hiệu SIGIO là tín hiệu Unix không có thông tin bổ sung. Nếu nguồn tín hiệu có nhiều lý do để tạo tín hiệu thì bộ thu tín hiệu không thể xác định chính xác điều gì đã xảy ra. Có tới bảy loại sự kiện tín hiệu được tạo ra bởi các socket TCP, vì vậy khi ứng dụng nhận được SIGIO, không có cách nào để phân biệt giữa chúng.

Tuy nhiên, mô hình IO điều khiển bằng tín hiệu có thể được sử dụng trong giao tiếp UDP, vì UDP chỉ có một sự kiện yêu cầu dữ liệu, có nghĩa là trong các trường hợp bình thường, quy trình UDP chỉ cần thu tín hiệu SIGIO và gọi lệnh gọi hệ thống đọc để đọc dữ liệu đến. Nếu một ngoại lệ xảy ra, một lỗi ngoại lệ sẽ được trả về.

Lạc đề ở đây, bạn có cho rằng ví dụ về việc chặn mô hình IO trong cuộc sống cũng giống như việc xếp hàng chờ lấy đồ ăn ở căng tin không? Bạn cần phải xếp hàng để nhận bữa ăn và phải đợi đầu bếp chuẩn bị các món ăn.

Hãy nói về Netty và nhìn vào mô hình IO từ góc độ kernel


Mô hình ghép kênh IO giống như xếp hàng chờ đợi ở cửa khách sạn để được gọi. Người gọi giống như select, poll và epoll, có thể quản lý thống nhất các sự kiện sẵn sàng bữa ăn của tất cả khách hàng. Khách hàng giống như ổ cắm kết nối. Ai có thể đi ăn sẽ được người gọi thông báo.

Hãy nói về Netty và nhìn vào mô hình IO từ góc độ kernel


##IO không đồng bộ (AIO)

Bốn mô hình IO được giới thiệu ở trên đều là IO đồng bộ và chúng sẽ chặn trong giai đoạn sao chép dữ liệu thứ hai.

Qua phần giới thiệu ở phần trước “Đồng bộ hóa và không đồng bộ”, tôi tin mọi người sẽ dễ dàng hiểu được mô hình IO không đồng bộ. Theo mô hình IO không đồng bộ, các hoạt động IO được kernel hoàn thành trong giai đoạn chuẩn bị dữ liệu và giai đoạn sao chép dữ liệu và sẽ không. Bất kỳ sự chặn nào do ứng dụng gây ra. Quá trình ứng dụng chỉ cần tham chiếu dữ liệu trong mảng được chỉ định.

Sự khác biệt chính giữa IO không đồng bộ và IO điều khiển bằng tín hiệu là IO điều khiển tín hiệu được hạt nhân thông báo khi một hoạt động IO có thể được bắt đầu, trong khi IO không đồng bộ được hạt nhân thông báo khi hoạt động IO đã hoàn thành.

Lấy một ví dụ trong cuộc sống: mô hình IO không đồng bộ giống như khi chúng ta vào phòng riêng trong nhà hàng cao cấp để ăn, chúng ta chỉ cần ngồi ở phòng riêng và gọi món (tương tự với các cuộc gọi IO không đồng bộ). Chúng ta không cần phải lo lắng bất cứ điều gì. Đến giờ uống rượu, là lúc trò chuyện. Sau khi bữa ăn đã sẵn sàng, người phục vụ (tương tự như kernel) sẽ đưa chúng ta vào phòng riêng (tương tự như không gian của người dùng). ). Toàn bộ quá trình không có bất kỳ trở ngại nào.

Hãy nói về Netty và nhìn vào mô hình IO từ góc độ kernel

Các cuộc gọi hệ thống IO không đồng bộ yêu cầu sự hỗ trợ từ nhân hệ điều hành. Hiện tại, chỉ IOCP trong Window triển khai cơ chế IO không đồng bộ rất hoàn thiện.

Hệ thống Linux chưa đủ trưởng thành để triển khai cơ chế IO không đồng bộ và sự cải thiện hiệu suất so với NIO là không rõ ràng.

Tuy nhiên, trong phiên bản 5.1 của nhân Linux, thư viện IO không đồng bộ mới io_uring đã được chủ nhân Facebook Jens Axboe giới thiệu, giúp cải thiện một số vấn đề về hiệu suất của AIO gốc Linux ban đầu. So với Epoll và AIO bản địa trước đó, hiệu suất đã được cải thiện rất nhiều, điều này đáng được quan tâm.

Ngoài ra, mô hình IO điều khiển bằng tín hiệu không áp dụng cho giao thức TCP nên hầu hết chúng hiện nay đều sử dụng mô hình ghép kênh IO.

Mô hình luồng IO

Trong phần giới thiệu nội dung trước, chúng tôi đã trình bày chi tiết về quy trình nhận và gửi các gói dữ liệu mạng, đồng thời giới thiệu năm mô hình IO để hiểu cách kernel đọc dữ liệu mạng và thông báo cho luồng người dùng.

Nội dung trước phân tích mô hình gửi và nhận dữ liệu mạng từ góc độ không gian hạt nhân. Trong phần này, chúng ta xem xét cách gửi và nhận dữ liệu mạng từ góc độ không gian người dùng.

So với kernel, mô hình luồng IO của không gian người dùng tương đối đơn giản. Các mô hình luồng IO trong không gian người dùng này đều thảo luận về ai chịu trách nhiệm nhận kết nối khi nhiều luồng làm việc cùng nhau, ai chịu trách nhiệm phản hồi việc đọc và ghi IO, ai chịu trách nhiệm tính toán và ai chịu trách nhiệm gửi và nhận. phân chia chế độ lao động của các luồng IO của người dùng.

lò phản ứng

Reactor sử dụng NIO để thực hiện các phân công lao động khác nhau trên các luồng IO:

  • Sử dụng mô hình ghép kênh IO mà chúng tôi đã đề cập trước đó, chẳng hạn như select, poll, epoll và kqueue, để đăng ký và giám sát các sự kiện IO.
  • Gửi các sự kiện IO đã sẵn sàng tới từng Trình xử lý cụ thể để xử lý sự kiện IO tương ứng.

Thông qua công nghệ ghép kênh IO, bạn có thể liên tục theo dõi các sự kiện IO và liên tục phân phối công văn, giống như một lò phản ứng, có vẻ như nó liên tục tạo ra các sự kiện IO, vì vậy chúng tôi gọi mô hình này là mô hình Lò phản ứng.

Chúng ta hãy xem ba cách phân loại mô hình Lò phản ứng:

Luồng đơn của lò phản ứng đơn


Hãy nói về Netty và nhìn vào mô hình IO từ góc độ kernel

Mô hình Reactor dựa trên công nghệ ghép kênh IO để giám sát các sự kiện IO, từ đó liên tục tạo ra các sự kiện sẵn sàng IO. Trong hệ thống Linux, chúng tôi sử dụng epoll để thực hiện ghép kênh IO. Hãy lấy hệ thống Linux làm ví dụ:

  • Một Reactor duy nhất có nghĩa là chỉ có một đối tượng epoll, được sử dụng để lắng nghe tất cả các sự kiện, chẳng hạn như sự kiện kết nối, sự kiện đọc và ghi.
  • Luồng đơn có nghĩa là chỉ có một luồng thực thi epoll_wait để có được các Ổ cắm sẵn sàng cho IO, sau đó đọc và ghi các Ổ cắm sẵn sàng này và quá trình xử lý công việc tiếp theo vẫn là luồng này.

Mô hình single-thread Reactor giống như chúng ta mở một nhà hàng rất nhỏ Với tư cách là ông chủ, chúng ta cần tự mình làm mọi việc, bao gồm: chào hỏi khách hàng (chấp nhận sự kiện), giới thiệu thực đơn cho khách hàng và chờ khách gọi món (IO). request), nấu ăn (xử lý công việc), phục vụ (IO reply), tiễn khách (ngắt kết nối).

Lò phản ứng đơn đa luồng

Khi số lượng khách tăng lên (yêu cầu đồng thời) thì hiển nhiên một mình chúng ta sẽ quá bận rộn để làm những việc trong nhà hàng (single-thread). Lúc này chúng ta cần tuyển thêm nhân viên (đa luồng) để giúp việc. chúng tôi làm những việc trên.

Vì vậy, có một mô hình đa luồng Reactor duy nhất:

Hãy nói về Netty và nhìn vào mô hình IO từ góc độ kernel

  • Trong chế độ này, chỉ có một đối tượng epoll để giám sát tất cả các sự kiện IO và một luồng gọi epoll_wait để lấy Ổ cắm sẵn sàng cho IO.
  • Nhưng khi các sự kiện sẵn sàng IO xảy ra, các sự kiện IO này tương ứng với các Trình xử lý nghiệp vụ được xử lý và chúng tôi thực thi chúng thông qua nhóm luồng. Điều này cải thiện hiệu quả thực thi so với mô hình đơn luồng Reactor đơn và phát huy tối đa lợi thế của CPU đa lõi.

Lò phản ứng chủ-nô đa luồng

Khi làm bất cứ việc gì, chúng ta phải ưu tiên mọi việc. Chúng ta nên ưu tiên và làm những việc có mức độ ưu tiên cao hơn một cách hiệu quả, thay vì làm tất cả mà không có mức độ ưu tiên.

Khi nhà hàng nhỏ của chúng tôi ngày càng có nhiều khách (đồng thời ngày càng lớn hơn), chúng tôi cần mở rộng quy mô của nhà hàng. Trong quá trình đó, chúng tôi nhận thấy việc đón khách là công việc quan trọng nhất của nhà hàng trước tiên. Khách vào, chúng ta không thể để khách rời đi ngay khi nhìn thấy đông khách, miễn là món ăn được nấu chậm hơn một chút cũng không thành vấn đề.

Kết quả là mô hình đa luồng Reactor master-slave đã ra đời:

Hãy nói về Netty và nhìn vào mô hình IO từ góc độ kernel

  • Chúng tôi đã thay đổi từ một Lò phản ứng ban đầu thành nhiều Lò phản ứng. Reactor chính được sử dụng để thực hiện việc có mức độ ưu tiên cao nhất trước tiên, đó là chào đón khách (xử lý các sự kiện kết nối) và Handler xử lý tương ứng là bộ chấp nhận trong hình.
  • Sau khi kết nối được tạo và ổ cắm tương ứng được thiết lập, sự kiện đọc cần theo dõi sẽ được đăng ký trong bộ chấp nhận cho Lò phản ứng phụ và Lò phản ứng phụ sẽ giám sát các sự kiện đọc và ghi trên ổ cắm.
  • Cuối cùng, quá trình xử lý logic nghiệp vụ đọc và viết được chuyển đến nhóm luồng để xử lý.

Lưu ý: Ở đây chỉ có sự kiện đọc được đăng ký với Reactor và sự kiện ghi không được đăng ký, vì sự kiện đọc được kích hoạt bởi kernel epoll và sự kiện ghi được kích hoạt bởi luồng công việc của người dùng (thời điểm gửi dữ liệu được xác định theo chuỗi công việc cụ thể) ), vì vậy sự kiện ghi phải được đăng ký bởi chuỗi công việc của người dùng.

Thời gian để luồng người dùng đăng ký sự kiện ghi là chỉ khi dữ liệu do người dùng gửi không thể được ghi vào bộ đệm cùng một lúc thì sự kiện ghi sẽ được đăng ký. Khi bộ đệm có thể ghi lại, nó sẽ tiếp tục ghi. dữ liệu đã gửi còn lại. Nếu luồng người dùng có thể. Tất cả dữ liệu đã gửi sẽ được ghi vào bộ đệm cùng một lúc, do đó không cần phải đăng ký sự kiện ghi vào Lò phản ứng phụ.

Mô hình đa luồng Reactor chủ-nô lệ là mô hình luồng IO được sử dụng trong hầu hết các khung mạng chính thống. Netty, chủ đề của loạt bài của chúng tôi, sử dụng mô hình này.

Thủ lĩnh

Proactor là mô hình phân chia luồng IO dựa trên AIO. Trước đó chúng tôi đã giới thiệu mô hình IO không đồng bộ, đây là mô hình lập trình không đồng bộ hoàn toàn được hỗ trợ bởi nhân hệ điều hành và không bị chặn trong giai đoạn chuẩn bị dữ liệu và giai đoạn sao chép dữ liệu.

Mô hình luồng ProactorIO để lại việc giám sát các sự kiện IO, thực thi các hoạt động IO và gửi kết quả IO tới kernel.

Hãy nói về Netty và nhìn vào mô hình IO từ góc độ kernel


Giới thiệu các thành phần của mô hình Proactor:

  • Trình xử lý hoàn thành là một hàm gọi lại hoạt động IO không đồng bộ được xác định bởi chương trình người dùng. Khi hoạt động IO không đồng bộ hoàn thành, nó sẽ được kernel gọi lại và thông báo về kết quả IO.
  • Hàng đợi sự kiện hoàn thành Sau khi hoàn thành thao tác IO không đồng bộ, sự kiện hoàn thành IO tương ứng sẽ được tạo và sự kiện hoàn thành IO sẽ được đưa vào hàng đợi.
  • Bộ xử lý hoạt động không đồng bộ chịu trách nhiệm thực thi IO không đồng bộ. Sau khi quá trình thực thi hoàn tất, sự kiện hoàn thành IO được tạo và đặt trong hàng đợi Hàng đợi sự kiện hoàn thành.
  • Proactor là trình điều phối vòng lặp sự kiện, chịu trách nhiệm thu thập các sự kiện hoàn thành IO từ Hàng đợi sự kiện hoàn thành và gọi lại trình xử lý hoàn thành được liên kết với sự kiện hoàn thành IO.
  • Bộ khởi tạo khởi tạo hoạt động không đồng bộ và đăng ký trình xử lý hoàn thành và bộ đo lường vào hạt nhân thông qua Bộ xử lý hoạt động không đồng bộ.

Quy trình thực hiện mô hình Proactor:

  • Chuỗi người dùng khởi tạo aio_read và cho kernel biết địa chỉ bộ đệm đọc trong không gian người dùng, để kernel hoàn thành thao tác IO và đưa kết quả vào bộ đệm đọc trong không gian người dùng. Chuỗi người dùng có thể đọc kết quả trực tiếp (không bị chặn). .
  • Bộ khởi tạo khởi tạo thao tác đọc không đồng bộ aio_read (thao tác không đồng bộ) và đăng ký trình xử lý hoàn thành vào kernel.

Sự kiện hoàn thành IO mà chúng ta quan tâm trong Proactor: kernel đã đọc dữ liệu cho chúng ta và đưa nó vào bộ đệm đọc mà chúng ta đã chỉ định và luồng người dùng có thể đọc dữ liệu đó trực tiếp. Trong Reactor, điều chúng ta quan tâm là sự kiện sẵn sàng IO: dữ liệu đã đến nhưng luồng người dùng cần tự đọc nó.

  • Lúc này user thread có thể làm những việc khác mà không cần chờ kết quả IO. Đồng thời, kernel bắt đầu thực hiện các thao tác IO không đồng bộ. Khi thao tác IO hoàn tất, một sự kiện hoàn thành sẽ được tạo và sự kiện hoàn thành IO sẽ được đặt trong hàng đợi sự kiện hoàn thành.
  • Proactor lấy sự kiện hoàn thành từ hàng đợi sự kiện hoàn thành và gọi lại trình xử lý hoàn thành được liên kết với sự kiện hoàn thành IO.
  • Hoàn tất quá trình xử lý logic nghiệp vụ trong trình xử lý hoàn thành.

So sánh giữa Reactor và Proactor

  • Reactor là mô hình luồng IO dựa trên NIO và Proactor là mô hình luồng IO dựa trên AIO.
  • Reactor quan tâm đến các sự kiện sẵn sàng IO và Proactor quan tâm đến các sự kiện hoàn thành IO.
  • Trong Proactor, chương trình người dùng cần chuyển địa chỉ bộ đệm đọc của không gian người dùng tới kernel. Không cần thiết cho Lò phản ứng. Điều này cũng dẫn đến việc mỗi hoạt động đồng thời trong Proactor yêu cầu một vùng bộ đệm độc lập, vùng này có mức tiêu hao nhất định trong bộ nhớ.
  • Logic triển khai của Proactor rất phức tạp và chi phí mã hóa cao hơn nhiều so với Reactor.
  • Hiệu suất của Proactor cao hơn Reactor khi xử lý IO tiêu tốn nhiều thời gian, nhưng việc cải thiện hiệu quả thực thi đối với IO tiêu tốn thời gian thấp là không rõ ràng.

Mô hình IO của Netty

Sau khi chúng tôi đã giới thiệu quy trình gửi và nhận các gói dữ liệu mạng trong kernel cũng như năm mô hình IO và hai mô hình luồng IO, bây giờ chúng ta hãy xem mô hình IO trong netty trông như thế nào.

Khi giới thiệu mô hình luồng Reactor IO, chúng tôi đã đề cập rằng có ba mô hình Reactor: luồng đơn Reactor đơn, đa luồng Reactor đơn và đa luồng Reactor chủ-phụ.

Ba mô hình Reactor này được hỗ trợ trong Netty, nhưng mô hình chúng tôi thường sử dụng là mô hình đa luồng Reactor chủ-nô lệ.

Ba Lò phản ứng mà chúng tôi giới thiệu trước đây chỉ là mô hình và ý tưởng thiết kế. Trên thực tế, các khung mạng khác nhau không được triển khai theo đúng mô hình. Sẽ có một số khác biệt nhỏ, nhưng ý tưởng thiết kế chung là giống nhau.

Chúng ta hãy cùng xem mô hình đa luồng Reactor master-slave trong netty trông như thế nào?

Hãy nói về Netty và nhìn vào mô hình IO từ góc độ kernel

  • Reactor xuất hiện dưới dạng nhóm trong netty. Reactor được chia thành hai nhóm trong netty. Một nhóm là MainReactorGroup, chính là EventLoopGroup bossGroup mà chúng ta thường thấy trong code. Nhóm còn lại là SubReactorGroup, đây là nhóm mà chúng ta thường thấy trong code. . Xem Nhóm công nhân EventLoopGroup.
  • Thường chỉ có một Reactor trong MainReactorGroup, chịu trách nhiệm thực hiện công việc quan trọng nhất đó là lắng nghe sự kiện chấp nhận kết nối. Khi một sự kiện kết nối xảy ra, NioSocketChannel tương ứng (đại diện cho kết nối Ổ cắm) được tạo và khởi tạo trong trình chấp nhận xử lý tương ứng. Sau đó chọn một Lò phản ứng trong SubReactorGroup theo cách cân bằng tải, đăng ký nó và lắng nghe sự kiện Đọc.

Lý do tại sao chỉ có một Reactor trong MainReactorGroup là vì thông thường chương trình máy chủ của chúng ta sẽ chỉ liên kết và lắng nghe một cổng. Nếu chúng ta muốn liên kết và lắng nghe nhiều cổng, chúng ta sẽ cấu hình nhiều Reactor.

  • Có nhiều Lò phản ứng trong SubReactorGroup. Số lượng Lò phản ứng cụ thể có thể được chỉ định bởi tham số hệ thống -D io.netty.eventLoopThreads. Số lượng Reactor mặc định là số lõi CPU * 2. Các Lò phản ứng trong SubReactorGroup chịu trách nhiệm chính trong việc giám sát các sự kiện đọc và ghi và mỗi Lò phản ứng chịu trách nhiệm giám sát một tập hợp các kết nối ổ cắm. Phân phối tất cả các kết nối giữa nhiều Lò phản ứng.
  • Lò phản ứng được chỉ định một luồng IO. Luồng IO này chịu trách nhiệm lấy các sự kiện sẵn sàng IO từ Lò phản ứng, thực hiện các lệnh gọi IO để lấy dữ liệu IO và thực thi PipeLine.

Kết nối Ổ cắm được gán cố định cho Lò phản ứng sau khi được tạo, do đó, kết nối Ổ cắm sẽ chỉ được thực thi bởi một luồng IO cố định. Mỗi kết nối Ổ cắm được gán một phiên bản PipeLine độc ​​lập để điều phối quá trình xử lý IO trên logic Kết nối ổ cắm. Mục đích của thiết kế tuần tự hóa không khóa này là để ngăn nhiều luồng thực hiện đồng thời xử lý logic IO trên cùng một kết nối ổ cắm và ngăn chặn các vấn đề về an toàn luồng. Đồng thời, thông lượng hệ thống được tối đa hóa.

Vì chỉ có một luồng IO trong mỗi Lò phản ứng nên luồng IO này không chỉ phải thực thi ChannelHandler trong PipeLine tương ứng với kết nối Ổ cắm hoạt động IO mà còn phải lấy sự kiện sẵn sàng IO từ Lò phản ứng và thực hiện các lệnh gọi IO. Do đó, logic được thực thi trong ChannelHandler trong PipeLine không thể mất quá nhiều thời gian. Hãy thử đưa quá trình xử lý logic nghiệp vụ tốn nhiều thời gian vào một nhóm luồng nghiệp vụ riêng biệt để xử lý. Nếu không, nó sẽ ảnh hưởng đến việc đọc và ghi IO của các kết nối khác, do đó sẽ ảnh hưởng hơn nữa. ảnh hưởng đến toàn bộ chương trình dịch vụ.

  • Khi yêu cầu IO hoàn thành quá trình xử lý logic nghiệp vụ tương ứng trong luồng nghiệp vụ, dữ liệu phản hồi sẽ được truyền ngược trong PipeLine bằng cách sử dụng tham chiếu ChannelHandlerContext được giữ trong luồng nghiệp vụ và cuối cùng được ghi lại cho máy khách.

Chúng tôi đã giới thiệu xong mô hình IO trong netty. Hãy giới thiệu ngắn gọn cách netty hỗ trợ ba mô hình Reactor được đề cập ở trên.

Định cấu hình một luồng đơn Reactor

Nhóm sự kiện sự kiệnNhóm = mới Nhóm NioEventLoop(1); Máy chủBootstrap máy chủBootstrap = mới Máy chủBootstrap(); máy chủBootstrap.nhóm(sự kiệnNhóm);

Cấu hình đa luồng Reactor đơn

Nhóm sự kiện sự kiệnNhóm = mới Nhóm NioEventLoop(); Máy chủBootstrap máy chủBootstrap = mới Máy chủBootstrap(); máy chủBootstrap.nhóm(sự kiệnNhóm);

Định cấu hình đa luồng cho Reactor master-slave

Nhóm sự kiện Ông chủNhóm = mới Nhóm NioEventLoop(1); Nhóm sự kiện Nhóm công nhân = mới Nhóm NioEventLoop(); Máy chủBootstrap máy chủBootstrap = mới Máy chủBootstrap(); máy chủBootstrap.nhóm(Ông chủNhóm, Nhóm công nhân);

Tóm tắt

Bài viết này là một bài viết tương đối nhiều thông tin. Nó sử dụng 25 hình ảnh và 22.336 từ. Nó bắt đầu bằng cách kernel xử lý quá trình gửi và nhận các gói dữ liệu mạng, sau đó giới thiệu tính năng chặn và không chặn và đồng bộ hóa thường gây nhầm lẫn từ góc độ kernel. và khái niệm về sự không đồng bộ. Sử dụng điều này làm cơ sở, chúng tôi đã giới thiệu năm mô hình IO thông qua câu hỏi C10K, sau đó giới thiệu các nguyên tắc chọn, thăm dò và epoll cũng như so sánh toàn diện của chúng dưới hình thức phát triển công nghệ trong ghép kênh IO. Cuối cùng, chúng tôi đã giới thiệu hai mô hình luồng IO và mô hình Lò phản ứng ở dạng netty.

Địa chỉ gốc: https://mp.weixin.qq.com/s/zAh1yD5IfwuoYdrZ1tGf5Q.

Cuối cùng, bài viết nói về Netty và xem xét mô hình IO từ góc độ kernel kết thúc ở đây. Nếu bạn muốn biết thêm về cách nói về Netty và xem xét mô hình IO từ góc nhìn kernel, vui lòng tìm kiếm các bài viết của CFSDN hoặc tiếp tục duyệt. các bài viết liên quan tôi hy vọng bạn sẽ ủng hộ blog của tôi trong tương lai! .

25 4 0
qq735679552
Hồ sơ

Tôi là một lập trình viên xuất sắc, rất giỏi!

Nhận phiếu giảm giá taxi Didi miễn phí
Phiếu giảm giá taxi Didi
Chứng chỉ ICP Bắc Kinh số 000000
Hợp tác quảng cáo: 1813099741@qq.com 6ren.com
Xem sitemap của VNExpress