文章

epoll

从进程阻塞、select 轮询到 epoll 就绪队列,理解 Java NIO Selector 背后的 Linux 事件通知机制。

epoll

Java NIO selector底层用的操作系统的epoll机制。

  1. 进程阻塞的原理
  2. 进程监听多个socket
    1. select
    2. epoll
    3. select vs. epoll
  3. 总结

进程阻塞的原理

比如进程等待从一个socket读数据,数据没到,进程阻塞。什么是阻塞?

进程(线程)状态

  • New:新创建的线程,尚未执行;
  • Runnable:运行中的线程,正在执行run()方法的Java代码;
  • Blocked:运行中的线程,因为某些操作被阻塞而挂起;
  • Waiting:运行中的线程,因为某些操作在等待中;
  • Timed Waiting:运行中的线程,因为执行sleep()方法正在计时等待;
  • Terminated:线程已终止,因为run()方法执行完毕。

Ref:

阻塞就是进程不在可执行队列(runnable)里,而是被扔到了阻塞队列(blocked)里。CPU只给Runnable里的进程分时间片,所以被阻塞的进程不会被CPU执行到,也不会消耗CPU。

这时候,可以认为等待socket的进程被扔到了该socket相关的阻塞队列。

stateDiagram-v2
    [*] --> Runnable: 进程可被调度
    Runnable --> Running: 抢到CPU时间片
    Running --> SocketWait: recv/read发现没数据
    SocketWait --> Runnable: 网卡中断 + socket可读
    Running --> Runnable: 时间片用完
    Running --> [*]: 进程退出

    note right of SocketWait
        不在CPU runnable队列里
        所以阻塞不消耗CPU
    end note

进程什么时候不再阻塞?怎么做到的?

当网线传来数据到网卡的时候:

  1. 网卡给CPU发了个中断,通知CPU从网卡读数据;
  2. CPU此时将数据从网卡copy到socket对应的内存某处(这是哪个socket的数据?数据有port信息,socket也有port信息,CPU会把该port的数据和该socket对应起来)。该socket相当于有数据可读了;
  3. 然后CPU将该socket的等待队列里的进程删掉,放回runnable队列;

现在,进程可执行了。当抢到CPU的时候,进程就能把该socket的数据从内存里读到应用程序内存了,进而执行应用程序逻辑处理这些数据。

Ref:

进程监听多个socket

比如一个服务器进程,可以和多个client建立socket连接,所以服务器需要监听多个socket。

怎么监听这么多socket?

  • BIO:一个线程监听一个socket,死等该socket的数据;
  • NIO:多路复用。当某个socket有数据的时候,由操作系统“通知”线程去读,线程不再阻塞。

这里所谓的“通知”,所谓的不再阻塞,其实就是OS将其中某个(某些)socket的数据从网卡复制到内存后,将这些socket的等待队列里的进程(线程)重新放回runnable队列。

因为linux是c写的,所以os的行为体现在几个c的函数上。

select

1
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval)

Redis - Client & Server中描述了所谓的文件描述符,可知进程内保留有该进程打开的所有文件信息,即文件描述符表。socket也是文件,所以进程读写socket,其实就是把该socket的文件描述信息放在自己的文件描述符表里。

进程调用select函数,传进来三个socket set,代表要对这些socket文件做的行为。然后阻塞。假设进程要做的只有读socket,os从网卡读完数据到内存之后,将进程踢回runnable,select不再阻塞,进程可以继续执行。

然后进程知道已经有socket的数据可读了,哪个可读?不知道,所以要轮询一下:

1
2
3
4
5
6
7
8
9
		select(max+1, &rset, NULL, NULL, NULL);
 
	for(i=0;i<5;i++) {
		if (FD_ISSET(fds[i], &rset)){
			memset(buffer,0,MAXBUF);
			read(fds[i], buffer, MAXBUF);
			puts(buffer);
		}
	}	

缺点:直观地讲,不知道哪个socket的数据准备好了。所以要遍历所有的socket,O(n)操作。如果socket很多,自然就慢些。

还有一个涉及到select实现细节的缺点:每次都要先把进程加入socket对应的等待队列,某socket数据准备好之后再把进程从所有socket等待队列删除,加入runnable队列。所以涉及对进程处理的所有socket的等待队列进行遍历。也因为此,select的实现规定了一个进程最多监听1024个socket。

Ref:

epoll

主要是epoll_create/epoll_ctl/epoll_wait几个函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
  struct epoll_event events[5];
  int epfd = epoll_create(10);
  ...
  ...
  for (i=0;i<5;i++) 
  {
    static struct epoll_event ev;
    memset(&client, 0, sizeof (client));
    addrlen = sizeof(client);
    ev.data.fd = accept(sockfd,(struct sockaddr*)&client, &addrlen);
    ev.events = EPOLLIN;
    epoll_ctl(epfd, EPOLL_CTL_ADD, ev.data.fd, &ev); 
  }
  
  while(1){
  	puts("round again");
  	nfds = epoll_wait(epfd, events, 5, 10000);
	
	for(i=0;i<nfds;i++) {
			memset(buffer,0,MAXBUF);
			read(events[i].data.fd, buffer, MAXBUF);
			puts(buffer);
	}
	}

哪几个socket有数据,os直接return。这样相当于用户可以以O(1)的时间复杂度获取到有数据的socket,快啊!所以每个进程最多监听1024个socket的限制也取消了。

当然,从epoll的实现细节入手的话,还会发现epoll不用来回来去copy进程监听的socket们对应的文件描述符列表。

sequenceDiagram
    participant App as 应用进程
    participant Epoll as epoll instance
    participant SockA as socket A
    participant Ready as ready list

    App->>Epoll: epoll_ctl(ADD, fdA)
    Epoll->>SockA: 挂到等待队列
    App->>Epoll: epoll_wait()
    Epoll-->>App: 没事件时阻塞
    SockA-->>Epoll: 数据到达,回调
    Epoll->>Ready: 加入就绪列表
    Ready-->>App: epoll_wait 返回 fdA
    App->>SockA: read(fdA)

select像是“醒了以后自己挨个问谁好了没”,epoll则是“好了的人已经被放到名单里了”。不是没有循环,而是不用每次从一堆没动静的fd里硬扫一遍,体感差距就像点名和看签到表。

select vs. epoll

Ref:

总之,epoll在监听socket数量多、且真正有数据的socket寥寥无几的情况下,很高效。如果监听socket数量很少,select本身就很快了。如果几乎所有的socket都有数据,那select和epoll也没什么区别了。

总结

其实就是c的网络编程、尤其是linux编程的细节……搞Java的可能对这些细节不是很感冒……了解一下就行……

本文由作者按照 CC BY 4.0 进行授权