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.

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ớ và 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>"}}.

Allocate một refc binary. Append chuỗi "<div>" vào binary. Append biến name vào binary. Và cuối cùng là append "</div>" vào binary. 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>" và "</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.

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:

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. 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. Tránh overhead - write(2) và 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ó. 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.

- 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.

- hầu hết các hàm I/O ( , , 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 .

Tham khảo