探索在同一臺機器上執行的不同程序之間的不同通訊方式,並儘可能快地完成。我們專注於高速程序間通訊 (IPC),但其中一些方法可以擴充套件到網路。我們將使用 Rust 進行探索。
由於這些是獨立的程序,因此我們在程序內採用的大多數方法都無法使用。這些技術不是執行緒間或非同步例程間的通訊,而是在不同程式間共享資料的技術。它們甚至可能都不是用 Rust 編寫的。
程式碼大部分都是片段,但完整的原始碼可以在這裡找到,最後還有基準測試結果。
案例
我們希望從一個程序向另一個程序傳送一條訊息(“ping”),並在收到訊息後回覆確認(“pong”)。這個迴圈讓我們有機會計算兩個程序之間往返所需的時間。計時很複雜,下面有說明,但我們將執行許多迴圈,並從那裡計算平均時間。
我們這裡關注的是低延遲,而不是高吞吐量。
方法 1 - 管道
這是連線同一臺機器上的程序時最先想到的方法。就像 cat | grep 一樣,我們只需將生產者的 stdout 連線到消費者的 stdin,反之亦然。這可以在 Windows、Linux 和 MacOS 上使用。
消費者程序從 stdin 向陣列中讀取五個位元組,檢查它們是否等於 ping 後的換行符,然後作出相應的響應。它也會響應 pong。
use std::io::{stdin, stdout, Read, Write}; fn main() { let mut arr = [0u8, 0, 0, 0, 0]; loop { let read_result = stdin().read_exact(&mut arr); if read_result.is_ok() { let output = match &arr { b<font>"ping\n" => b"pong\n", b"pong\n" => b"ping\n", _ => b"Error", }; stdout().write(output).unwrap(); } } }
|
生產者程序稍微複雜一些,因為它必須先建立和處理消費者,但它會發出 ping,等待響應,如果沒有 ping 就會驚慌失措。
pub fn run_inner(&mut self, n: usize, mut return_value: &mut [u8; 5]) { if let Some(ref mut pipes_input) = self.pipe_proc.stdin { if let Some(ref mut pipes_output) = self.pipe_proc.stdout { for _ in 0..n { pipes_input.write(b<font>"ping\n").unwrap(); pipes_output.read_exact(return_value).unwrap(); if return_value != b"pong\n" { panic!("Unexpected response") } } } } }
|
除了對管道進行一些繁瑣的 ref mut 處理外,編寫這個程式還是很容易的。如果使用更復雜的資料結構,這可能會很麻煩,因為必須決定資訊之間的分隔符,而不僅僅是換行符。
方法 2 - TCP
一種自然的方法是嘗試透過 HTTP 連線客戶端和伺服器。但這感覺就像對 HTTP 伺服器進行基準測試一樣危險,所以我直接使用 TCP。
...
<font>// Producer<i> impl TcpRunner { pub fn new(start_child: bool, tcp_nodelay: bool) -> TcpRunner { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let port = listener.local_addr().unwrap().port(); let exe = crate::executable_path("tcp_consumer"); let child_proc = if start_child { Some(Command::new(exe).args(&[port.to_string(), tcp_nodelay.to_string()]).spawn().unwrap()) } else { None }; let stream = TcpStreamWrapper::from_listener(listener, tcp_nodelay); Self { child_proc, wrapper: stream, tcp_nodelay } } pub fn run(&mut self, n: usize, print: bool) { // TODO: Decide whether this can be done without copying from the socket <i> let mut buf = [0u8; 4]; for _ in 0..n { self.wrapper.stream.write(b"ping").unwrap(); self.wrapper.stream.read_exact(&mut buf).unwrap(); if !buf.eq(b"pong") { panic!("Sent ping didn't get pong") } } } }
...
// pipes_consumer.rs<i> // Consumer<i> fn main() { let args: Vec<String> = std::env::args().collect(); let port = u16::from_str(&args[1]).unwrap(); let nodelay = bool::from_str(&args[2]).unwrap(); let mut wrapper = ipc::tcp::TcpStreamWrapper::from_port(port, nodelay); let mut buf = [0u8; 4]; while let Ok(_) = wrapper.stream.read(&mut buf) { if buf.eq(b"ping") { wrapper.stream.write(b"pong").unwrap(); } else { panic!("Received unknown value {:?}", buf) } } }
|
總而言之,這相當簡單。目前,ping 被寫入套接字,複製,然後檢查。然後 Pong 被寫回。程式碼中有一條註釋突出顯示了這一點,但我不清楚是否可以在不將其複製到本地緩衝區的情況下讀取套接字。考慮到它只有 5 個位元組,並且無論如何都需要系統呼叫,這可能是可以忽略不計的。唯一值得關注的另一項是,我們可以設定 TCP_NODELAY,這將禁用Nagle 演算法。通常,TCP 會短暫等待以構建一個足夠大的值得傳送的資料包。考慮到我們正在尋找快速傳輸,禁用此功能是有意義的。給出了有和沒有此設定的基準測試。劇透 - 它似乎沒有改變任何東西。
從實現角度來看,它比前一種情況稍微複雜一些。必須將要連線的埠傳遞給消費者,建立連線,但並不太難。我可以輕鬆地編寫此程式碼,而且我也喜歡它可以在需要時跨網路拆分。對於複雜的用例,我可能會錯過一些 HTTP 細節,但對於來回傳送資料包,這感覺靈活且易於維護。
方法 3 - UDP
自然,下一個方法是嘗試 UDP。在這些情況下,UDP 傳統上用於“發射後不管”機制。與 TCP 不同,該協議不提供恢復丟失或無序資料包的方法。這可能是一個優勢,因為它可以防止連線變得過於“繁瑣”,但如果一致性很重要,則需要手動實現這些層 - 無論是帶內還是帶外。我們將回避這個討論,因為我們在同一臺機器上執行兩個程序並使用環回介面卡,但請注意,這種方式仍然可能丟失資料包。如果套接字緩衝區中填充的資料多於在讀取迴圈中可以讀取的資料,它將被毫無歉意地丟棄。也許另一篇文章會演示這一點。
我僅展示生產者,因為消費者非常相似。
pub struct UdpRunner { child_proc: Option<Child>, wrapper: UdpStreamWrapper, their_port: u16, } impl UdpRunner { pub fn new(start_child: bool) -> UdpRunner { let wrapper = UdpStreamWrapper::new(); let their_port = portpicker::pick_unused_port().unwrap(); let exe = crate::executable_path(<font>"udp_consumer"); let child_proc = if start_child { Some( Command::new(exe) .args(&[wrapper.our_port.to_string(), their_port.to_string()]) .spawn() .unwrap(), ) } else { None }; // Awkward sleep to make sure the child proc is ready <i> sleep(Duration::from_millis(100)); wrapper .socket .connect(format!("127.0.0.1:{}", their_port)) .expect("Child process can't connect"); Self { child_proc, wrapper, their_port, } } pub fn run(&mut self, n: usize, print: bool) { let start = Instant::now(); let mut buf = [0u8; 4]; for _ in 0..n { self.wrapper.socket.send(b"ping").unwrap(); self.wrapper.socket.recv(&mut buf).unwrap(); if !buf.eq(b"pong") { panic!("Sent ping didn't get pong") } } } }
|
總體來說,這種方法還不錯,但由於以下幾個原因,它顯得更加繁瑣:
- 由於 UDP 是一種廣播協議,它並不關心是否有人在監聽。這意味著我們必須啟動消費者,連線到生產者,並確認它們已連線。這可以在帶外完成,但我只是透過休眠一段時間來破解它,這段時間似乎足夠讓消費者醒來並準備好接收指令
- 該 API 與 TCP API 類似,但含義不同。具體來說,該connect方法不保證已建立任何連線,只是程式已將自身繫結到遠端地址,該地址可能會或可能不會隨後失敗,或者只是將資料泵入乙太網。它像 TCP 連線方法一樣採用地址陣列,但它沒有有意義的方法來決定地址是否有用(因為它沒有握手),因此只採用第一個地址並繫結到它。所有這些都在文件中,但它不符合人體工程學。也許bind會是一個更好的名字,儘管它確實有一個可能不合適的特定含義
UDP 的這些缺點眾所周知,它用於這些缺點不那麼重要或非同步特性有用的場合。它還可以附加多個偵聽器,而 TCP 則無法做到這一點。人們可以看到 UDP 在哪裡可能有用,以及為什麼 UDP 在線上遊戲等用例中無處不在。方法 4——共享記憶體
共享記憶體是一種在程序間共享資料的快速方法。一個程序分配一塊記憶體,並將該控制代碼傳遞給另一個程序。然後每個程序都可以獨立地讀取或寫入該記憶體塊。如果你的第一直覺是擔心同步和競爭條件,那你絕對正確。更糟糕的是,開箱即用的 Rust 在這裡幫不了我們,儘管通常對這類事情很有幫助。我們只能靠自己,而且會這樣unsafe。
首先,我們將編寫在生產者和消費者中執行的程式碼來建立(或獲取)某些共享記憶體的控制代碼,然後將其佈置為生產者鎖、消費者鎖和一個四位元組緩衝區,以供我們交換資料。
<font>// Shared memory layout<i> //| 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |<i> //| producer lock | consumer lock | data buffer (ping or pong) |<i> pub struct ShmemWrapper { pub shmem: Shmem, pub owner: bool, pub our_event: Box<dyn EventImpl>, pub their_event: Box<dyn EventImpl>, pub data_start: usize, } impl ShmemWrapper { pub fn new(handle: Option<String>) -> ShmemWrapper { let owner = handle.is_none(); // If we've been given a memory handle, attach it, if not, create one <i> let mut shmem = match handle { None => shmem_conf().create().unwrap(), Some(h) => shmem_conf() .os_id(&h) .open() .expect(&format!("Unable to open the shared memory at {}", h)), }; let mut bytes = unsafe { shmem.as_slice_mut() }; // The two events are locks - one for each side. Each side activates the lock while it's <i> // writing, and then unlocks when the data can be read<i> let ((our_event, lock_bytes_ours), (their_event, lock_bytes_theirs)) = unsafe { if owner { ( BusyEvent::new(bytes.get_mut(0).unwrap(), true).unwrap(), BusyEvent::new(bytes.get_mut(2).unwrap(), true).unwrap(), ) } else { ( // If we're not the owner, the events have been created already <i> BusyEvent::from_existing(bytes.get_mut(2).unwrap()).unwrap(), BusyEvent::from_existing(bytes.get_mut(0).unwrap()).unwrap(), ) } }; // Confirm that we've correctly indexed two bytes for each lock <i> assert!(lock_bytes_ours <= 2); assert!(lock_bytes_theirs <= 2); if owner { our_event.set(EventState::Clear).unwrap(); their_event.set(EventState::Clear).unwrap(); } ShmemWrapper { shmem, owner, our_event, their_event, data_start: 4, } } pub fn signal_start(&mut self) { self.our_event.set(EventState::Clear).unwrap() } pub fn signal_finished(&mut self) { self.our_event.set(EventState::Signaled).unwrap() } pub fn write(&mut self, data: &[u8; 4]) { let mut bytes = unsafe { self.shmem.as_slice_mut() }; for i in 0..data.len() { bytes[i + self.data_start] = data[i]; } } pub fn read(&self) -> &[u8] { unsafe { &self.shmem.as_slice()[self.data_start..self.data_start + 4] } } }
|
有了這些結構,我們只需在被允許時鎖定、寫入、解鎖,然後讀取即可。pub fn run(&mut self, n: usize, print: bool) { for _ in 0..n { <font>// Activate our lock in preparation for writing <i> self.wrapper.signal_start(); self.wrapper.write(b"ping"); // Unlock after writing <i> self.wrapper.signal_finished(); // Wait for their lock to be released so we can read <i> if self.wrapper.their_event.wait(Timeout::Infinite).is_ok() { let str = self.wrapper.read(); if str != b"pong" { panic!("Sent ping didn't get pong") } } } }
|
這段程式碼寫起來非常糟糕,而且花了不少時間才弄好。我幾乎可以肯定其中還有 bug。它之所以複雜是因為:
- 我們必須自己完成所有同步,而不需要太多幫助。在某些情況下,你可以想象使用佇列或訊息系統在程序之間進行帶外通訊,讓它們知道何時可以安全地讀取或寫入。但是,考慮到我們的訊息非常小,這會破壞我們努力實現的所有效能
- 這是非常低階的。我們必須自己整理位元組,並使用大量不安全的方法來完成這項工作。這也使我們暴露於結構佈局的變化,這會導致難以發現的錯誤
- 我遇到了幾個錯誤。在功能最豐富的板條箱的 Windows 實現中,分配記憶體頁面時存在最小頁面大小錯誤
- 目前尚不清楚底層記憶體是否可以輕鬆調整大小。對於我們的目的而言,這不是問題,因為我們只有 8 個位元組,但你可以想象這很快就會成為一個問題
說實話,除非我絕對確定我需要所有的共享記憶體效能,否則我不會想在生產中使用這樣的程式碼。其他語言有共享記憶體框架來簡化這樣的事情,但我在 Rust 中找不到任何東西。
測試結果
Windows 和 Linux 的結果,但由於它們是不同的機器,因此請謹慎對待這些結果。不過,在平臺內進行比較可能更公平。
- 使用共享記憶體,我們可以在 200 納秒內(大約 1000 個處理器週期)完成一次乒乓。
- 其他方法的每次操作時間都低於共享記憶體
結論
我對大多數事物的表現如此相似感到驚訝。我粗略地調查了 Linux 特定的方法,如 dbus 和 Unix Domain Sockets,但它們似乎與非共享記憶體方法大致相同。唯一可以嘗試的其他方法是記憶體對映檔案,但我想把它留到我想對更大的資料塊嘗試類似的東西時再試。
如果我必須在生產中執行此操作,對於大多數工作負載,我可能仍會使用 HTTP / TCP 連線。它可移植、在訊息失敗時可靠,並且如果需要,我可以將其拆分到多臺機器上。但是,對於延遲確實很重要的情況,使用共享記憶體的維護開銷是值得的。