Một Số Tối Ưu Hóa Nhỏ Cho Tầng Mạng Của Skynet - nói dối e blog

Một Số Tối Ưu Hóa Nhỏ Cho Tầng Mạng Của Skynet

Vào năm 2017, tôi đã thực hiện một vài cải tiến đáng kể cho cơ chế ghi dữ liệu của tầng mạng Skynet. Ý tưởng chính là: khi socket không ở trong trạng thái hoạt động cao, thay vì phải chuyển dữ liệu sang luồng mạng để ghi, chúng ta có thể ghi trực tiếp vào socket. Giải pháp này giúp giảm tải cho tầng mạng chạy đơn luồng, đặc biệt hiệu quả trong các trường hợp yêu cầu ghi dữ liệu liên tục.

Gần đây, tôi nhận ra rằng nếu phần lớn các trường hợp đều có thể ghi dữ liệu trực tiếp vào socket mà không cần chuyển tiếp qua luồng mạng, chúng ta còn có thể tối ưu thêm một bước nữa: loại bỏ thao tác sao chép bộ nhớ không cần thiết.

Thiết kế ban đầu hoạt động như sau:

  • Mọi dữ liệu cần gửi sẽ được người gửi đóng gói thành một đối tượng dữ liệu, sau đó chuyển tiếp cho luồng mạng.
  • Khi luồng mạng hoàn tất việc gửi dữ liệu, đối tượng này mới được giải phóng.

Đối tượng dữ liệu này ban đầu chỉ hỗ trợ vùng nhớ được cấp phát bằng hàm malloc. Do đó, giao diện API yêu cầu người dùng truyền vào một con trỏ void* và độ dài size_t. Điều này đòi hỏi người dùng phải tuân thủ quy tắc: con trỏ phải được cấp phát bằng malloc.

Tuy nhiên, nhiều nhà phát triển mong muốn có thể gửi cùng một khối dữ liệu cho nhiều socket mà không cần sao chép dữ liệu nhiều lần. Giải pháp phổ biến là thêm cơ chế đếm tham chiếu (reference counting) vào đối tượng dữ liệu. Để đáp ứng nhu cầu này, tôi đã mở rộng khả năng của đối tượng dữ liệu mạng bằng cách cho phép hỗ trợ các định dạng tùy biến thông qua API socket_server_userobject.

Vấn đề về mở rộng giao diện cũ:

  • Để giữ tính tương thích ngược (ABI), tôi đã áp dụng một thủ thuật: sử dụng giá trị độ dài -1 để đánh dấu đây là đối tượng do người dùng định nghĩa (vì độ dài của vùng nhớ cấp phát bằng malloc luôn ≥ 0).
  • Tuy nhiên, đây không phải là cách mở rộng giao diện tốt. Trong lần cập nhật này, tôi quyết định chuẩn hóa lại bằng cách thêm tệp tiêu đề mới socket_buffer.h chứa cấu trúc dữ liệu mới cho việc gửi gói tin mạng.

Cấu trúc dữ liệu mới:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
#define SOCKET_BUFFER_MEMORY 0
#define SOCKET_BUFFER_OBJECT 1
#define SOCKET_BUFFER_RAWPOINTER 2

struct socket_sendbuffer {
  int id;        // ID của socket
  int type;      // Loại dữ liệu (MEMORY/OBJECT/RAWPOINTER)
  const void *buffer; // Con trỏ đến vùng dữ liệu
  size_t sz;     // Kích thước dữ liệu
};

Giải thích các loại dữ liệu:

  • SOCKET_BUFFER_MEMORY: Tương ứng với vùng nhớ cấp phát bằng malloc như trước đây.
  • SOCKET_BUFFER_OBJECT: Đối tượng tùy biến do người dùng định nghĩa.
  • SOCKET_BUFFER_RAWPOINTER: Con trỏ thô, cho phép Skynet sử dụng trực tiếp dữ liệu mà không cần sao chép. Tuy nhiên, nếu cần lưu trữ để sử dụng sau này (ví dụ: chuyển tiếp cho tầng mạng), người dùng phải tự thực hiện sao chép.

Lợi ích thực tế:

  • Với API skynet.socket.send trong Lua, khi gửi một chuỗi Lua, chúng ta không cần phải cấp phát vùng nhớ mới bằng malloc và sao chép dữ liệu vào đó nữa. Thay vào đó, chỉ cần truyền địa chỉ bộ nhớ của chuỗi Lua cho Skynet. Nếu dữ liệu có thể được gửi ngay lập tức, quá trình sao chép không cần thiết sẽ được loại bỏ.

Cập nhật API mới:

  • Tôi đã thêm một nhóm API mới thay thế cho các API cũ:
    • skynet_socket_sendbuffer thay thế cho skynet_socket_send
  • Các API cũ vẫn được giữ lại thông qua macro để đảm bảo tính tương thích:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
static inline void sendbuffer_init_(struct socket_sendbuffer *buf, int id, const void *buffer, int sz) {
  buf->id = id;
  buf->buffer = buffer;
  if (sz < 0) {
    buf->type = SOCKET_BUFFER_OBJECT;
  } else {
    buf->type = SOCKET_BUFFER_MEMORY;
  }
  buf->sz = (size_t)sz;
}

static inline int skynet_socket_send(struct skynet_context *ctx, int id, void *buffer, int sz) {
  struct socket_sendbuffer tmp;
  sendbuffer_init_(&tmp, id, buffer, sz);
  return skynet_socket_sendbuffer(ctx, &tmp);
}

Triết lý thiết kế tầng mạng Skynet: Mục tiêu ban đầu của tôi khi thiết kế tầng mạng Skynet là:

  1. Sao chép dữ liệu từ không gian nhân (kernel space) sang không gian người dùng (user space).
  2. Chuyển địa chỉ dữ liệu này cho các dịch vụ khác nhau sử dụng.
  3. Chuyển dữ liệu cần gửi từ không gian người dùng sang không gian nhân.

Tôi cho rằng trong đa số trường hợp,瓶颈 không nằm ở tầng truyền tải mạng. Nói cách khác, chi phí CPU cho việc truyền nhận dữ liệu mạng thấp hơn nhiều so với chi phí xử lý dữ liệu đó. Do đó, việc sử dụng một luồng duy nhất để xử lý tất cả kết nối mạng là hoàn toàn khả thi, vì tốc độ xử lý của một nhân CPU đơn lẻ đủ để theo kịp băng thông của card mạng.

Các bước tối ưu tiếp theo:

  • Tách biệt đọc/ghi: Đa số thao tác ghi có thể được chuyển ra khỏi luồng mạng.
  • Tối ưu đọc dữ liệu: Hiện tại tôi chưa thấy cần thiết phải tách thao tác đọc ra khỏi luồng mạng. Tuy nhiên, trong tương lai có thể sẽ thêm nhiều luồng đọc dữ liệu, để luồng mạng chỉ tập trung vào thao tác poll.

Đề xuất cải tiến cho lớp bao bọc socket trong Lua: Tôi cho rằng vẫn còn nhiều không gian để cải tiến việc chuyển dữ liệu từ không gian người dùng sang máy

0%