本篇文章的問題是,在 EPOLLET 模式下,socket的 EPOLLIN 和 EPOLLOUT 是何時觸發的?
由于epollin比較簡單,我們先來看這個,
根據epoll相關的man檔案我們可以知道,epollin表示有資料可讀,所以它發生的時間必然是有新的tcp資料到來,
我們來寫段代碼驗證下:
#include <arpa/inet.h>
#include <assert.h>
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <strings.h>
#include <sys/epoll.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <unistd.h>
#define PORT 9999
#define MAX_EVENTS 10
static int tcp_listen() {
int lfd, opt, err;
struct sockaddr_in addr;
lfd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
assert(lfd != -1);
opt = 1;
err = setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
assert(!err);
bzero(&addr, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = INADDR_ANY;
addr.sin_port = htons(PORT);
err = bind(lfd, (struct sockaddr *)&addr, sizeof(addr));
assert(!err);
err = listen(lfd, 8);
assert(!err);
return lfd;
}
static void epoll_ctl_add(int epfd, int fd, int evts) {
struct epoll_event ev;
ev.events = evts;
ev.data.fd = fd;
int err = epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &ev);
assert(!err);
}
static void handle_events(struct epoll_event *e, int epfd) {
printf("events %d: ", e->data.fd);
if (e->events & EPOLLIN) {
printf("EPOLLIN ");
e->events &= ~EPOLLIN;
}
if (e->events & EPOLLOUT) {
printf("EPOLLOUT ");
e->events &= ~EPOLLOUT;
}
assert(e->events == 0);
printf("\n");
}
int main(int argc, char *argv[]) {
int epfd, lfd, cfd, err, n;
struct epoll_event events[MAX_EVENTS];
epfd = epoll_create1(0);
assert(epfd != -1);
lfd = tcp_listen();
epoll_ctl_add(epfd, lfd, EPOLLIN);
for (;;) {
n = epoll_wait(epfd, events, MAX_EVENTS, -1);
assert(n != -1);
for (int i = 0; i < n; i++) {
if (events[i].data.fd != lfd) {
handle_events(&events[i], epfd);
continue;
}
cfd = accept(lfd, NULL, NULL);
assert(cfd != -1);
err = fcntl(cfd, F_SETFL, O_NONBLOCK);
assert(!err);
epoll_ctl_add(epfd, cfd, EPOLLIN | EPOLLOUT | EPOLLET);
}
}
return 0;
}
這段代碼中我們主要關注的就是handle_events方法,該方法會輸出socket的epollin或epollout事件,
該程式作為我們的服務端,而客戶端我們用ncat來模擬,下面我們來執行看下,
【文章福利】需要C/C++ Linux服務器架構師學習資料加群812855908(資料包括C/C++,Linux,golang技術,Nginx,ZeroMQ,MySQL,Redis,fastdfs,MongoDB,ZK,流媒體,CDN,P2P,K8S,Docker,TCP/IP,協程,DPDK,ffmpeg等)

下面是服務端輸出:
$ gcc server.c && ./a.out
events 5: EPOLLOUT
events 5: EPOLLIN EPOLLOUT
events 5: EPOLLIN EPOLLOUT
events 5: EPOLLIN EPOLLOUT
下面是客戶端輸出:
$ ncat localhost 9999
1
2
^C
客戶端及服務端的執行步驟如下:
-
編譯并執行服務端程式,此時服務端在等待客戶端連接,終端里沒有任何輸出,
-
執行ncat命令,建立從客戶端到服務端的tcp連接,此時,服務端的終端會輸出第一個epollout事件,原因我們后邊講epollout時會說到,
-
在客戶端終端輸入1,此時服務端終端會輸出epollin和epollout,epollin產生的原因是因為客戶端發來資料,此時服務端的socket可讀,epollout產生的原因是因為服務端的socket可寫,
-
在客戶端終端輸入2,此時服務端終端還是會輸出epollin和epollout,原因如3,
-
用ctrl-c關閉ncat模擬的客戶端,此時服務端還是會輸出epollin和epollout,epollout產生的原因不變,epollin產生的原因多了個RCV_SHUTDOWN,這個我們后邊會講到,
總之,epollin事件產生的原因就是因為有新資料到來,對應到內核的原始碼為:
// net/ipv4/tcp_input.c
void tcp_data_ready(struct sock *sk)
{
...
sk->sk_data_ready(sk);
}
該方法會呼叫sk->sk_data_ready指向的方法,作用是通知epoll,該socket有epollin事件發生,
sk->sk_data_ready對應的方法為sock_def_readable,有興趣的同學可以沿著這個方法繼續看下,
// net/core/sock.c
static void sock_def_readable(struct sock *sk)
{
struct socket_wq *wq;
...
wq = rcu_dereference(sk->sk_wq);
if (skwq_has_sleeper(wq))
wake_up_interruptible_sync_poll(&wq->wait, EPOLLIN | EPOLLPRI |
EPOLLRDNORM | EPOLLRDBAND);
...
}
所以,當我們在客戶端終端輸入1、2時,服務端的socket就會收到epollin事件,
那為什么我們關閉客戶端,服務端還是會收到epollin呢?這就和下面這個方法有關系了,
// net/ipv4/tcp.c
__poll_t tcp_poll(struct file *file, struct socket *sock, poll_table *wait)
{
__poll_t mask;
struct sock *sk = sock->sk;
const struct tcp_sock *tp = tcp_sk(sk);
int state;
...
state = inet_sk_state_load(sk);
...
mask = 0;
...
// 該socket的既是RCV_SHUTDOWN,又是SEND_SHUTDOWN,或者狀態是TCP_CLOSE
// 對應的epoll事件都是EPOLLHUP
if (sk->sk_shutdown == SHUTDOWN_MASK || state == TCP_CLOSE)
mask |= EPOLLHUP;
// 該socket是RCV_SHUTDOWN,比如對方用shutdown(sockfd, SHUT_WR)方法
// 關閉它的SEND_SHUTDOWN,也就是關閉了我們的RCV_SHUTDOWN
// 又比如,我們用shutdown(sockfd, SHUT_RD)方法,關閉我們自己的RCV_SHUTDOWN
// 在此模式下,epoll事件為EPOLLIN
if (sk->sk_shutdown & RCV_SHUTDOWN)
mask |= EPOLLIN | EPOLLRDNORM | EPOLLRDHUP;
// 當我們的socket處于TCP_ESTABLISHED等狀態時
if (state != TCP_SYN_SENT &&
(state != TCP_SYN_RECV || tp->fastopen_rsk)) {
...
// 如果我們的socket里有可讀位元組,epoll對應的事件就是EPOLLIN
if (tcp_stream_is_readable(tp, target, sk))
mask |= EPOLLIN | EPOLLRDNORM;
if (!(sk->sk_shutdown & SEND_SHUTDOWN)) {
// 如果我們的socket有可寫空間,epoll事件就是EPOLLOUT
if (sk_stream_is_writeable(sk)) {
mask |= EPOLLOUT | EPOLLWRNORM;
} else {
...
}
} else
// 如果我們的socket關閉了SEND_SHUTDOWN,epoll事件就是EPOLLOUT
mask |= EPOLLOUT | EPOLLWRNORM;
...
} else if (state == TCP_SYN_SENT && inet_sk(sk)->defer_connect) {
...
}
...
// 如果我們的socket發生錯誤了,epoll事件就是EPOLLERR
if (sk->sk_err || !skb_queue_empty(&sk->sk_error_queue))
mask |= EPOLLERR;
return mask;
}
EXPORT_SYMBOL(tcp_poll);
當內核通知epoll,某個socket有事件發生時,epoll會呼叫上面的tcp_poll方法,檢查該socket到底有什么事件發生,所以該方法是tcp/epoll體系中的非常重要的一個方法,它最終決定了用戶能看到socket發生的哪些事件,
而且,該方法回傳給用戶的是,該socket此時所有滿足條件的事件,例如上面有新資料到達后,內核會呼叫tcp_data_ready,通知epoll該socket有epollin事件發生,但是,epoll會再次呼叫tcp_poll方法,檢查到原來不止是有epollin事件,還有epollout事件,
所以,我們上面的服務端終端會同時輸出epollin和epollout,
看到這個方法,我們也就理解了,為什么上面操作流程5中說到,epollin產生的原因多了個RCV_SHUTDOWN,因為當我們關閉客戶端時,服務端的socket會收到tcp的fin包,它的shutdown狀態會設定為RCV_SHUTDOWN,
由上面的所有分析可知,epollin事件產生的原因是:
-
有新資料到達,socket可讀,
-
對方關閉了連接或只關閉了SEND_SHUTDOWN,導致我們關閉了RCV_SHUTDOWN,
下面我們來看下epollout產生的原因,
由上面我們可以知道,epollin產生的原因是因為有新資料到達,那epollout產生的原因是不是因為,在我們往socket中寫資料后,該資料有部分或全部被發送成功呢?
看上去好像是這樣,不過我們要用實體驗證看下,
我們先將上面代碼的handle_events部分改成下面這樣:
static void handle_events(struct epoll_event *e, int epfd) {
int n = write(e->data.fd, "hi\n", 3);
assert(n == 3);
printf("events %d: ", e->data.fd);
if (e->events & EPOLLIN) {
printf("EPOLLIN ");
e->events &= ~EPOLLIN;
}
if (e->events & EPOLLOUT) {
printf("EPOLLOUT ");
e->events &= ~EPOLLOUT;
}
assert(e->events == 0);
printf("\n");
}
該方法里,就是在最開始的地方加了個write方法,
那是不是說,在此種模式下,程式會陷入死回圈呢?
因為tcp一連接上,就會有epollout事件發生,然后我們就往socket中寫了些資料,該資料發送完畢之后又會觸發epollout,然后又發資料,這樣就進入了死回圈,
是這樣嗎?我們來執行看下,
下面是服務端輸出:
$ gcc server.c && ./a.out
events 5: EPOLLOUT
下面是客戶端輸出:
$ ncat localhost 9999
hi
執行流程為:
-
開啟服務端,等待客戶端連接,此時服務端終端沒有任何輸出,
-
用ncat模擬客戶端連服務端,在連接上之后,服務端會輸出epollout,客戶端會輸出hi,說明服務端的資料確實發到了客戶端,
-
沒有任何其他的反應了,
可以看到,這個輸出和我們預想的并不一樣,服務端的tcp在發送完資料后,并沒有通知給我們epollout事件,所以沒有我們上文猜測的死回圈出現,
這是為什么呢?如果此時不通知,什么時候才會通知呢?
如果細心讀過epoll檔案的朋友可能會注意到下面這段話:
The suggested way to use epoll as an edge-triggered (EPOLLET) interface is as follows:
i with nonblocking file descriptors; and
ii by waiting for an event only after read(2) or write(2) return EAGAIN.
條件1我們是滿足的,條件2我們不滿足,難道是因為這個原因?
看下原始碼:
// net/ipv4/tcp.c
int tcp_sendmsg_locked(struct sock *sk, struct msghdr *msg, size_t size)
{
...
struct sk_buff *skb;
...
int copied = 0;
...
long timeo;
...
// 因為我們設定了nonblock,所以該方法會設定timeo為0
timeo = sock_sndtimeo(sk, flags & MSG_DONTWAIT);
...
restart:
...
while (msg_data_left(msg)) {
int copy = 0;
skb = tcp_write_queue_tail(sk);
if (skb)
copy = size_goal - skb->len;
// 如果skb中沒空間了,或者該skb不能在尾部添加資料,我們就需要新創建一個skb
if (copy <= 0 || !tcp_skb_can_collapse_to(skb)) {
...
// 我們該socket的記憶體使用量超過了系統要求的最大使用量
// 就等待sndbuf中的資料發送出去,這樣可以有額外空間,
// 將我們的資料寫到sndbuf中
if (!sk_stream_memory_free(sk))
goto wait_for_sndbuf;
...
skb = sk_stream_alloc_skb(sk, 0, sk->sk_allocation,
first_skb);
...
}
...
if (skb_availroom(skb) > 0 && !zc) {
// 拷貝資料到socket的sndbuf
copy = min_t(int, copy, skb_availroom(skb));
err = skb_add_data_nocache(sk, skb, &msg->msg_iter, copy);
...
}
...
copied += copy;
...
continue;
wait_for_sndbuf:
set_bit(SOCK_NOSPACE, &sk->sk_socket->flags);
...
// 由于我們是nonblock模式,該方法會回傳錯誤碼EAGAIN,之后該錯誤碼又會回傳給用戶
err = sk_stream_wait_memory(sk, &timeo);
if (err != 0)
goto do_error;
...
}
...
return copied + copied_syn;
...
}
EXPORT_SYMBOL_GPL(tcp_sendmsg_locked);
由該方法我們可以看到,一直write直到回傳EAGAIN,和只是write一次的區別是,有EAGAIN的設定了SOCK_NOSPACE,沒EAGAIN的沒設定,是這個原因?
我們來看下tcp中,收到ack包對應的內核代碼,因為收到ack,才能證明對方收到資料,我們才可以丟掉sndbuf里的資料,
// include/net/sock.h
static inline void sk_wmem_free_skb(struct sock *sk, struct sk_buff *skb)
{
sock_set_flag(sk, SOCK_QUEUE_SHRUNK);
...
__kfree_skb(skb);
}
在收到對方的ack后,tcp的sndbuf里的sk_buff會被釋放掉,上面的方法就是對應的釋放方法,
由上可見,該方法在釋放skb之前,還設定了sk的flag為SOCK_QUEUE_SHRUNK,
在釋放完ack對應的sk_buff之后,又會呼叫下面的方法:
// net/ipv4/tcp_input.c
static void tcp_check_space(struct sock *sk)
{
if (sock_flag(sk, SOCK_QUEUE_SHRUNK)) {
sock_reset_flag(sk, SOCK_QUEUE_SHRUNK);
...
if (sk->sk_socket &&
test_bit(SOCK_NOSPACE, &sk->sk_socket->flags)) {
tcp_new_space(sk);
...
}
}
}
該方法經過各種判斷之后,最侄訓呼叫方法:
// net/ipv4/tcp_input.c
static void tcp_new_space(struct sock *sk)
{
...
sk->sk_write_space(sk);
}
而這個方法,會呼叫sk->sk_write_space指向的方法,通知epoll,該socket有epollout事件發生,
所以說,只要滿足tcp_check_space方法中的各種條件,epoll就會被通知,我們的socket有epollout事件,那我們的代碼里也就會輸出epollout,并陷入死回圈,
讓我們來看下tcp_check_space方法中的各種條件我們是否都滿足,
首先是檢測sock的flag中是否有SOCK_QUEUE_SHRUNK,在上面的sk_wmem_free_skb方法中,我們可以看到這個flag是設定了的,所以這個條件滿足,
其次它會檢測是否有SOCK_NOSPACE這個flag,由tcp_sendmsg_locked方法可以看到,如果一直write,直到回傳EAGAIN,這個flag是設定了的,如果write沒回傳EAGAIN,則沒有這個flag,
綜上可知,由write導致的epollout事件,是要滿足下面的各種條件才會發生,
首先,要一直write,直到回傳EAGAIN,此時socket的send buffer是被占滿的,
其次,當send buffer里的資料被發送并釋放到一定程度時,tcp才會告知epoll,該socket有epollout事件發生,
我們用代碼來實際驗證下,
我們先將handle_events方法改成如下,再執行服務端程式,客戶端還是用ncat模擬:
static void handle_events(struct epoll_event *e, int epfd) {
int n;
char buf[8192];
while (1) {
n = write(e->data.fd, buf, 8192);
if (n == -1) {
assert(errno == EAGAIN);
break;
}
}
printf("events %d: ", e->data.fd);
if (e->events & EPOLLIN) {
printf("EPOLLIN ");
e->events &= ~EPOLLIN;
}
if (e->events & EPOLLOUT) {
printf("EPOLLOUT ");
e->events &= ~EPOLLOUT;
}
assert(e->events == 0);
printf("\n");
}
當tcp建立連接之后,我們會發現服務端終端一直輸出epollout,進入了死回圈,和我們分析的結果是一樣的,
下面我們來總結下epollout產生的原因:
-
建立tcp連接,這部分我們沒有原始碼分析,不過由于比較簡單,有興趣的同學可以自己看下,
-
一直write,直到回傳EAGAIN,然后等到write的資料發送完到一定程度后,
還有些其他的不是很重要的原因,我們這里就不說了,
轉載請註明出處,本文鏈接:https://www.uj5u.com/ruanti/233159.html
標籤:其他
上一篇:vulnhub dc5
