IO data và vectored I/O
elixir
37
White

Cẩm Huỳnh viết ngày 16/07/2018

String và I/O là hai trong những thứ mà lập trình viên nào cũng đụng phải hằng ngày. Bạn làm web, bạn build template bằng string. Bạn cào dữ liệu, bạn parse đoạn mã HTML cũng là string. Bạn viết driver Redis, bạn cũng dùng string. Tìm hiểu về String và cách tối ưu hóa nó trong hệ thống là một trong những cách để tăng hiệu năng ứng dụng của bạn.

Trong bài viết này ta cũng lướt qua "string" implementation trong Elixir và cách nó tối ưu hóa với Vectored I/O.

Như thường lệ, bài viết được đăng lại từ Blog Quần Cam.

IO data là gì?

Khi làm việc với các thao tác I/O trong Elixir, bạn thường bắt gặp một kiểu dữ liệu là iodata. Đây là một kiểu dữ liệu đặc biệt trong Elixir, có typespec là iolist() | binary().

Binary trong Elixir là biểu diễn của các bit octet. Để khai báo một binary trong Elixir bạn dùng <<>>, ví dụ như chuỗi "Cẩm" sẽ được biểu diễn bằng binary dưới đây.

iex> <<67, 225, 186, 169, 109>>
"Cẩm"

Còn IO list là một list pha lẫn giữa các byte, binary và chính IO list. Ví dụ sau đây là một IO list cấu thành nên một file XML.

xml =
  [
    ['<?xml', [32, 'version', 61, 34, "1.0", 34], [], [], '?>'],
    [60, "person", 32, "gender", 61, 34, "male", 34],
    62,
    ["Cam"],
    [60, 47, "person", 62]
]

Để chuyển từ IO list sang binary, ta có thể dùng hàm IO.iodata_to_binary/1.

iex> IO.iodata_to_binary(xml)
"<?xml version=\"1.0\"?><person gender=\"male\">Cam</person>"

IO data có ích gì?

Lý do chủ yếu để dùng IO data là tránh lãng phí bộ nhớbinary copying khi xây dựng chuỗi.

Ví dụ như để xây dựng một đoạn HTML để trả về cho người dùng, trong Elixir ta hay dùng toán tử <>.

IO.inspect("<div>" <> name <> "</div>")

Để thực hiện đoạn code này, máy ảo BEAM sẽ phải lần lượt chạy các instructions sau:

{bs_init2,{f,0},{x,1},0,1,{field_flags,[]},{x,1}}.
{bs_put_string,5,{string,"<div>"}}.
{bs_put_binary,{f,0},{atom,all},8,{field_flags,[unsigned,big]},{x,0}}.
{bs_put_string,6,{string,"</div>"}}.
  1. Allocate một refc binary.
  2. Append chuỗi "<div>" vào binary.
  3. Append biến name vào binary.
  4. Và cuối cùng là append "</div>" vào binary.
  5. Sau đó allocate và copy chính đoạn binary này để thực hiện IO.inspect.

Như vậy khi dùng toán tử <> cho mỗi lần nối chuỗi, ta allocate binary mới cho chuỗi đầu ra và các chuỗi trung gian, gây lãng phí bộ nhớ và tăng thêm áp lực cho garbage collector.

Thay vì cấu trúc binary, ta có thể làm tốt hơn bằng cách build IO data.

html = ["<div>", name, "</div>"]

Ở đoạn code trên, ta tạo ra một IO list kết hợp từ những "mảnh" nhỏ binary. Thoạt nhìn, đoạn code trên có vẻ không khác gì thao tác append vào chuỗi. Nhưng về bản chất nó append từng binary vào một linked list (thao tác này có độ phức tạp là O(1)). Hơn thế nữa, các "mảnh" binary lúc này được sử dụng như những con trỏ nhờ tận dụng việc binary có thể được share trong máy ảo Erlang.

Đoạn code sau đây ví dụ cách sinh ra đoạn HTML chứa danh sách người dùng dùng IO data.

def users_list(users) do
  for user <- users do
    ["<div>", user.name, "</div>"]
  end
end

Ở đây "<div>""</div>" ở được máy ảo Erlang tái sử dụng trong IO data được sinh ra, nhờ đó bộ nhớ được tiết kiệm đáng kể. Tưởng tượng nếu thực hiện nối chuỗi bằng <>, đoạn binary mở tag và đóng tag sẽ được allocate lại trong mỗi vòng lặp gây lãng phí và làm giảm hiệu năng hệ thống.

iodata1

Không dừng lại ở đó, việc dùng IO data còn giúp bạn tối ưu hóa cho Vectored I/O ở dưới tầng máy ảo.

Vectored I/O

Vectored I/O, còn được gọi là Scatter/Gather I/O, là một phương thức I/O giúp đọc data từ nhiều buffers khác nhau rồi ghi vào cùng một data stream; hoặc đọc data từ một data stream rồi ghi vào nhiều buffer khác nhau.

C cung cấp system call writev(2) để cài đặt Vectored I/O. Hàm này nhận tham số đầu vào là file descriptor fd, một danh sách iovec *iov và tổng số iovec truyền vào trong *iov, và trả về số lượng bytes đã được ghi vào fd.

ssize_t writev(int fd, const struct iovec *iov, int iovcnt);

Với iovec là struct chứa pointer đến nơi bắt đầu của buffer và độ dài của nó.

struct iovec {
    void  *iov_base;    /* Starting address */
    size_t iov_len;     /* Number of bytes to transfer */
};

Một ví dụ đơn giản dùng Vectored I/O.

#include <sys/uio.h>
#include <stdio.h>
#include <string.h>
#include <fcntl.h>

int main() {
  ssize_t bytes_written;

  int fd = open("/tmp/test.html", O_WRONLY|O_CREAT|O_APPEND, 0644);
  char *buffer0 = "<div>";
  char *buffer1 = "John";
  char *buffer2 = "</div>";

  int iov_count;
  struct iovec iov[3];

  iov[0].iov_base = buffer0;
  iov[0].iov_len = strlen(buffer0);
  iov[1].iov_base = buffer1;
  iov[1].iov_len = strlen(buffer1);
  iov[2].iov_base = buffer2;
  iov[2].iov_len = strlen(buffer2);

  iov_count = sizeof(iov) / sizeof(struct iovec);

  bytes_written = writev(fd, iov, iov_count);

  printf("finished writting %ld bytes to file", bytes_written);
  return 0;
}

// finished writting 15 bytes to file

Sử dụng Vectored I/O giúp tăng sự hiệu quả và tính tiện lợi trong các thao tác đọc ghi:

  1. Thao tác I/O trên những data không nằm trên các block liền kề trên bộ nhớ - data được scatter (vương vãi) ở những vectors khác nhau, và được gather (gom) lại ở trong system.

  2. Thao tác I/O với nhiều buffer khác nhau với chỉ một system call - ví dụ muốn đọc ghi N buffer khác nhau với write(2) hoặc read(2), bạn sẽ cần gọi N system call.

  3. Tránh overhead - write(2)read(2) cũng đòi hỏi bạn phải allocate một memory block lớn và dùng memcpy() để copy data từ các buffer vào nó.

  4. Atomic I/O - Kernel sẽ ngăn không cho process nào có thể thực hiện I/O trên fd đầu vào, từ đó đảm bảo data integrity.

Vectored I/O và Elixir

Elixir, mặc dù là một ngôn ngữ máy ảo, vẫn tối ưu hóa cho Vectored I/O, thông qua IO list mà tui đã giới thiệu ở phần đầu của bài viết.

Giả sử ta mở một socket đến localhost:8080 và bắt đầu gửi dữ liệu.

{:ok, socket} =
  :gen_tcp.connect('localhost', 8080, [
    :binary,
    packet: 0,
    active: false
  ])

:gen_tcp.send(socket, ["<div>", hello, "</div>"])

Và khi chạy đoạn code trên IEx và dùng các phần mềm tracing như strace hay dtruss, bạn sẽ thấy writev được gọi rất nhiều.

SYSCALL(args)                    = return
writev(0x0, 0x18581008, 0x1)     = 2 0
writev(0x1A, 0xB038D270, 0x4)    = 15 0 // *** data sending
writev(0x0, 0x18581008, 0x1)     = 28 0
writev(0x0, 0x18581008, 0x1)     = 9 0

Như các bạn thấy syscall thứ 2 là nơi gửi dữ liệu, với 0x1A là file descriptor, iovec đầu tiên có địa chỉ 0xB038D270 và có tổng cộng 4 iovec được gửi, và có tổng cộng 15 byte được ghi.

Nếu dùng đoạn dtrace script của Evan Miller trong bài viết tuyệt vời của anh ấy về chủ đề này, ta sẽ có kết quả chi tiết hơn.

3    404    writev:return Writev data 1/4: (0 bytes): 0x0000000000000000 \0
3    404    writev:return Writev data 2/4: (5 bytes): 0x0000000012040490 <div>
3    404    writev:return Writev data 3/4: (4 bytes): 0x0000000012041478 John
3    404    writev:return Writev data 4/4: (6 bytes): 0x00000000120404d0 </div>

Bài viết này sẽ giúp tui tăng lương như thế nào?

  • Dùng IO data khi cần build string - giảm lãng phí memory và tăng hiệu năng hệ thống. Bạn có thể xem cách Saxy build IO data tại module này.
  • Thao tác I/O với IO data - hầu hết các hàm I/O (:file.write, :gen_tcp.send, v.v) và các thư viện chính (Phoenix, Cowboy, hackney) trong Elixir đều hỗ trợ IO data.
  • Dùng các lib hỗ trợ build IO data - để tận dụng Vectored I/O trong hệ thống của bạn ngay từ bước "gather". Nếu một thư viên không hỗ trợ bạn build IO data, thứ nhất là nó chậm, thứ hai là gây lãng phí bộ nhớ. Một số thư viện hỗ trợ IO data: Saxy, Jason, Saxy, Msgpax, Saxy :ok_hand:.

Tham khảo

  1. Elixir and IO Lists, Part 1: Building Output Efficiently.
  2. Elixir RAM and the Template of Doom.
  3. What are the pros and cons of vectored Read/Writes on Linux Systems?.
Bình luận


White
{{ comment.user.name }}
Bỏ hay Hay
{{comment.like_count}}
Male avatar
{{ comment_error }}
Hủy
   

Hiển thị thử

Chỉnh sửa

White

Cẩm Huỳnh

47 bài viết.
463 người follow
Kipalog
{{userFollowed ? 'Following' : 'Follow'}}
Cùng một tác giả
White
69 5
image cover]imgcover] “Make it work, make it right, make it fast.” Bạn vừa viết xong một ứng dụng web :tada:. Mọi thứ chạy ổn. Code cũng đã được...
Cẩm Huỳnh viết gần 2 năm trước
69 5
White
48 26
Vừa rồi mình vừa tiết kiệm được $5 mỗi tháng sau khi migrate cái (Link) từ Digital Ocean sang Heroku Free Dyno. (Ảnh) Kết quả thật mĩ mãn vì hầu ...
Cẩm Huỳnh viết 3 năm trước
48 26
White
46 9
(Ảnh) Vì sao lại là Bật Đèn? Ai từng đọc qua Tắt Đèn hẳn đã biết tác phẩm được kết thúc bằng tình huống: Buông tay, chị vội choàng dậy, mở cửa...
Cẩm Huỳnh viết hơn 3 năm trước
46 9
Bài viết liên quan
White
9 6
Chưa xem phần 2? Xem (Link) Trong bài viết này tôi giới thiệu cho các bạn về khái niệm function arity, một cách gọi mĩ miều của số lượng argument ...
Lơi Rệ viết 5 năm trước
9 6
White
6 1
Bạn đang viết application với Elixir? Bạn sắp release sản phẩm hay đơn giản thỉnh thoảng bạn không biết tại sao service A lại lăn đùng ra chết hay ...
Trần Việt Thắng viết gần 2 năm trước
6 1
White
1 0
Custom Ecto.Type Version hiện tại của Ecto.Type không support một số datatype sử dụng khi validate. Ví dụ như MapSet. Thành ra đành phải tự viết đ...
Vie viết 3 năm trước
1 0
{{like_count}}

kipalog

{{ comment_count }}

bình luận

{{liked ? "Đã kipalog" : "Kipalog"}}


White
{{userFollowed ? 'Following' : 'Follow'}}
47 bài viết.
463 người follow

 Đầu mục bài viết

Vẫn còn nữa! x

Kipalog vẫn còn rất nhiều bài viết hay và chủ đề thú vị chờ bạn khám phá!