理一理Edge-triggered和Level-triggered

fairjm發表於2019-10-07

本文來自於 fairjm@圖靈社群 轉截請註明出處
部落格連結: https://fairjm.github.io/2019/10/03/et-lt-intro/


最近群裡又在討論java的NIO,提到了NIO使用的lt而netty使用JNI在linux和MacOS/BSD中封裝了et. 之前對這兩個概念籠統瞭解了下,並沒有去查閱額外資料,僅限知道lt在緩衝區還有資料的情況下就會被poll出來,而et則需要有新的請求/事件發生.
這次查閱了點資料,彙總一些資料來簡單(畢竟也沒有那麼深入..)談談這兩個概念.

簡介

這兩個名詞應該來源於電氣,用於啟用電路.摘取一段爆棧網的回答:

Level Triggering: In level triggering the circuit will become active when the gating or clock pulse is on a particular level. This level is decided by the designer. We can have a negative level triggering in which the circuit is active when the clock signal is low or a positive level triggering in which the circuit is active when the clock signal is high.
Edge Triggering: In edge triggering the circuit becomes active at negative or positive edge of the clock signal. For example if the circuit is positive edge triggered, it will take input at exactly the time in which the clock signal goes from low to high. Similarly input is taken at exactly the time in which the clock signal goes from high to low in negative edge triggering. But keep in mind after the the input, it can be processed in all the time till the next input is taken.

來源.
翻譯就是LT是根據設定的閾值來控制是否激發而ET是根據訊號的高到低或低到高這個變化來控制.

字面意思上level就是水平指的是某值,而edge是邊,是值和值之間的變遷.
用來控制操作是否進行的一種機制.

對應於epoll中的概念也類似.
拿文件中的例子:

  1. The file descriptor that represents the read side of a pipe (rfd) is registered on the epoll instance.
  2. A pipe writer writes 2 kB of data on the write side of the pipe.
  3. A call to epoll_wait(2) is done that will return rfd as a ready file descriptor.
  4. The pipe reader reads 1 kB of data from rfd.
  5. A call to epoll_wait(2) is done.

使用LT的話,因為依舊有1KB殘留所以wait會立即返回開始下一次操作,而ET這次變遷已經結束了,但因為沒有處理完所以後續變化也不會再返回了.(Since the read operation done in 4 does not consume the whole buffer data, the call to epoll_wait(2) done in step 5 might block indefinitely.)

ET在使用上建議遵循以下的規則:

i with nonblocking file descriptors; and
ii by waiting for an event only after read(2) or write(2) return EAGAIN.

非阻塞的檔案描述符(FD或SD)以及要read或write在返回EAGAIN的情況下才開始這個描述符的下一次事件監聽.(對於網路IO來說也可能是EWOULDBLOCK, 表示被標記為非阻塞的操作會發生阻塞的異常)

相對來說使用ET的要求和操作會需要更嚴謹一些,當然LT寫得有問題,比如漏了一個事件的處理,可能會導致出現跑滿CPU的死迴圈.

簡單使用

這裡使用mio來演示這兩種的使用方式.程式碼直接改的官網TcpServer的示例.
完整程式碼見:main.rs.

首先最外層是一樣的,選擇感興趣的事件註冊到poll中,poll進行wait等待可用事件.事件的處理上就有些許差異.

首先是LT:

fn read_level(stream: &mut TcpStream, poll: &Poll) -> Result<()> {
    let mut connection_closed = false;
    loop {
        let mut buf = vec![0u8; 8];
        match stream.read(&mut buf) {
            Ok(0) => {
                connection_closed = true;
                break;
            }
            Ok(_n) => {
                println!("======come new read======");
                println!("read:{:?}", String::from_utf8(buf));
                // just return is ok for level
                return Ok(());
            }
            Err(ref err) if would_block(err) => {
                println!("would_block happened");
                break;
            }
            Err(ref err) if interrupted(err) => {
                println!("interrupted happened");
                continue;
            }
            Err(err) => return Err(err),
        }
    }
    if connection_closed {
        // must have this one
        poll.deregister(stream)?;
        println!("{:?} Connection closed", stream.peer_addr());
    }
    Ok(())
}

OK(_n)中,當前只讀取部分byte並返回是可以的,並且如果未讀完會立即發起下一個事件.但要注意如果連線關閉需要移除,不然可能會有奇怪的問題.(在本機上如果不移除可能會導致無限的read事件)

而ET的話就需要讀到出現WouldBlock

fn read_edge(stream: &mut TcpStream) -> Result<()> {
    let mut connection_closed = false;
    loop {
        let mut buf = vec![0u8; 8];
        match stream.read(&mut buf) {
            Ok(0) => {
                connection_closed = true;
                break;
            }
            Ok(_n) => {
                println!("======come new read======");
                println!("read:{:?}", String::from_utf8(buf));
            }
            Err(ref err) if would_block(err) => {
                println!("would_block happened");
                // edge rely this to return, without this or just return after read(like level)
                // the connection will not be read anymore
                break;
            }
            Err(ref err) if interrupted(err) => {
                println!("interrupted happened");
                continue;
            }
            // Other errors we'll consider fatal.
            Err(err) => return Err(err),
        }
    }
    if connection_closed {
        println!("{:?} Connection closed", stream.peer_addr());
    }
    Ok(())
}

這裡無法像LT一樣直接讀完部分返回,需要在出現阻塞的情況下才能繼續操作.
因為mio相對底層,所以描述起來和epoll文件也類似,java的NIO也類似,但因為只提供了LT所以就沒有ET什麼事了.

選擇

ET的問題主要是需要更嚴謹的操作,而LT是更頻繁的wait喚醒.
在某種程度上,ET更加'惰性'而LT更加積極,你如果不處理或者還不想處理就會反覆收到事件,除非你取消註冊他.
所以在不少java的NIO程式碼中,常常會有channel在讀後取消讀註冊寫,在寫後取消寫註冊讀的程式碼,當可以寫,但是寫的內容還沒準備好時,使用LT會遇到不少問題(所以一般是準備寫的內容已經好了,再給channel註冊上寫的興趣).
ET在讀上會更加麻煩,不讀完等到返回EAGAIN,該描述符可能之後的事件觸發就會有問題.
具體還需要看自己的需求和場景. 此外多執行緒場景下,在同一個描述符上等待ET是保證只會喚醒一個執行緒,但要注意多個不同的資料塊請求可能會導致在一個FD/SD上返回多個事件,需要使用EPOLLONESHOT來確保只返回一個(這個flag是接受到一個事件後就解綁和FD/SD關係的).

java中只支援LT,netty通過JNI實現了ET,選擇的原因在一個郵件裡提到了一些Any reason why select() uses only level-triggered notification mode?.
大致的意思是ET和I/O提供的方法更加耦合,可能是為了更高的相容性放棄了這個機制的提供吧.

這邊就進行了簡單的一些資料整合,沒有涉及到更深的內容,如果文中有什麼錯誤歡迎評論.

參考資料:
https://netty.io/wiki/native-transports.html

http://man7.org/linux/man-pages/man7/epoll.7.html

https://github.com/tokio-rs/mio

相關文章