Linux Socket
我们知道应用程序是无法直接操作系统硬件,java\实现的Socket通信其实都是通过调用,封装操作系统内核的API
什么是Socket
Socket(套接字)可以看成是两个网络应用程序进行通信时,各自通信连接中的端点。
我们都知道一个连接对应着两个端点,一边是客户端地址,另一边是服务器端地址。
用一个套子,一头套住客户端,一头套住服务器端,是不是就形成了一个通道,我们的数据便从这条通道走过,通信也就开始了。
下图是一个基于TCP/IP协议的socket
Linux中Socket的系统调用
”一切皆文件"和文件描述符
“一切皆文件”是Unix/Linux基本哲学之一。在linux系统下,所有东西都实现了open,read,write,close等接口。
如果一个东西像文件一样能打开,能从里面读数据,能向里面写数据,也能关闭它,那它不就是文件吗?
同理,Linux中的Socket也不例外。但它不是物理意义上的文件,它存在于虚拟文件系统(VFS)中。是逻辑意义上的”文件“
(PS:Linux支持各种各样的文件系统格式,如ext2、ext3、reiserfs、FAT、NTFS、iso9660等等,不同的磁盘分区、光盘或其它存储设备都有不同的文件系统格式,然而这些文件系统都可以mount到某个目录下,使我们看到一个统一的目录树,各种文件系统上的目录和文件我们用ls命令看起来是一样的,读写操作用起来也都是一样的,这是怎么做到的呢?Linux内核在各种不同的文件系统格式之上做了一个抽象层,使得文件、目录、读写访问等概念成为抽象层的概念,因此各种文件系统看起来用起来都一样(VFS作用),这个抽象层称为虚拟文件系统VFS)
文件描述符(fd)是一个简单的正整数,每打开一个文件就会获得一个fd,用以标明每一个被进程所打开的文件。每个进程在PCB(Process Control Block)中保存着一份文件描述符表,文件描述符就是这个表的索引,每个表项都有一个指向已打开文件的filp指针。
意思是:文件描述表[ fd ] = 指向file结构体的指针filp
file结构体才是内核中用于描述文件属性的结构体
关于Liunx 中的fd和filp,更多内容可以参考这篇文章https://www.cnblogs.com/aaronLinux/p/5617070.html
1.socket()函数:创建一个socket
int socket(int domain, int type, int protocol);
socket用于创建一个socket,返回指向它的文件描述符,后续要通过文件描述符对这个socket做各种操作
socket函数的三个参数分别为
- domain:即协议域,又称为协议族(family)。常用的协议族有,AF_INET、AF_INET6、AF_LOCAL(或称AF_UNIX,Unix域socket)、AF_ROUTE等等。协议族决定了socket的地址类型,在通信中必须采用对应的地址,如AF_INET决定了要用ipv4地址(32位的)与端口号(16位的)的组合、AF_UNIX决定了要用一个绝对路径名作为地址。
- type:指定socket类型。常用的socket类型有,SOCK_STREAM、SOCK_DGRAM、SOCK_RAW、SOCK_PACKET、SOCK_SEQPACKET等等。
- protocol:故名思意,就是指定协议。常用的协议有,IPPROTO_TCP、IPPTOTO_UDP、IPPROTO_SCTP、IPPROTO_TIPC等,它们分别对应TCP传输协议、UDP传输协议、STCP传输协议、TIPC传输协议(这个协议我将会单独开篇讨论!)。
当我们调用socket创建一个socket时,返回的socket描述字是没有一个具体的地址(IPV4,IPV6协议的地址IP:Port)。如果想要给它赋值一个地址,就必须调用bind()函数,否则就当调用connect()、listen()时系统会自动随机分配一个端口。
2.bind()函数:将socket指定一个特定地址
正如上面所说bind()函数把一个地址族中的特定地址赋给socket。例如对应AF_INET、AF_INET6就是把一个ipv4或ipv6地址和端口号组合赋给socket。
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
函数的三个参数分别为:
- sockfd:即socket描述字,它是通过socket()函数创建了,唯一标识一个socket。bind()函数就是将给这个描述字绑定一个名字。
- addr:一个const struct sockaddr指针,指向要绑定给sockfd的协议地址。这个地址结构根据地址创建socket时的地址协议族的不同而不同,如ipv4对应的是:
struct sockaddr_in {
sa_family_t sin_family; /* address family: AF_INET */
in_port_t sin_port; /* port in network byte order */
struct in_addr sin_addr; /* internet address */ };
/* Internet address. */
struct in_addr {
uint32_t s_addr; /* address in network byte order */ };
ipv6对应的是:
struct sockaddr_in6 {
sa_family_t sin6_family; /* AF_INET6 */
in_port_t sin6_port; /* port number */
uint32_t sin6_flowinfo; /* IPv6 flow information */
struct in6_addr sin6_addr; /* IPv6 address */
uint32_t sin6_scope_id; /* Scope ID (new in 2.4) */ };
struct in6_addr {
unsigned char s6_addr[16]; /* IPv6 address */ };
Unix域对应的是:
#define UNIX_PATH_MAX 108
struct sockaddr_un {
sa_family_t sun_family; /* AF_UNIX */
char sun_path[UNIX_PATH_MAX]; /* pathname */ };
- addrlen:对应的是地址的长度。
通常服务器在启动的时候都会绑定一个众所周知的地址(ip地址+端口号),用于提供服务,客户就可以通过它来接连服务器;而客户端就不用指定,有系统自动分配一个端口号和自身的ip地址组合。这就是为什么通常服务器端在listen之前会调用bind(),而客户端就不会调用,而是在connect()时由系统随机生成一个。
3.listen()、connect()函数:监听(服务器)或连接(客户端)
如果作为一个服务器,在调用socket()、bind()之后就会调用listen()来监听这个socket,如果客户端这时调用connect()发出连接请求,服务器端就会接收到这个请求。
int listen(int sockfd, int backlog);
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
listen函数的第一个参数即为要监听的socket描述字,第二个参数为相应socket可以排队的最大连接个数。socket()函数创建的socket默认是一个主动类型的,listen函数将socket变为被动类型的,等待客户的连接请求。
connect函数的第一个参数即为客户端的socket描述字,第二参数为服务器的socket地址,第三个参数为socket地址的长度。客户端通过调用connect函数来建立与TCP服务器的连接。
4. accept()函数:服务器建立与客户端的连接
TCP服务器端依次调用socket()、bind()、listen()之后,就会监听指定的socket地址了。TCP客户端依次调用socket()、connect()之后就想TCP服务器发送了一个连接请求。TCP服务器监听到这个请求之后,就会调用accept()函数取接收请求,这样连接就建立好了。之后就可以开始网络I/O操作了,即类同于普通文件的读写I/O操作。
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
accept函数的第一个参数为服务器的socket描述字,第二个参数为指向struct sockaddr *的指针,用于返回客户端的协议地址,第三个参数为协议地址的长度。如果accpet成功,那么其返回值是由内核自动生成的一个全新的描述字,代表与返回客户的TCP连接。
注意:accept的第一个参数为服务器的socket描述字,是服务器开始调用socket()函数生成的,称为监听socket描述字;而accept函数返回的是已连接的socket描述字。一个服务器通常通常仅仅只创建一个监听socket描述字,它在该服务器的生命周期内一直存在。内核为每个由服务器进程接受的客户连接创建了一个已连接socket描述字,当服务器完成了对某个客户的服务,相应的已连接socket描述字就被关闭。
5、read()、write()等函数
万事具备只欠东风,至此服务器与客户已经建立好连接了。可以调用网络I/O进行读写操作了,即实现了网咯中不同进程之间的通信!网络I/O操作有下面几组:
read()/write()
recv()/send()
readv()/writev()
recvmsg()/sendmsg()
recvfrom()/sendto()
我推荐使用recvmsg()/sendmsg()函数,这两个函数是最通用的I/O函数,实际上可以把上面的其它函数都替换成这两个函数。它们的声明如下:
#include <unistd.h>
ssize_t read(int fd, void *buf, size_t count);
ssize_t write(int fd, const void *buf, size_t count);
#include <sys/types.h>
#include <sys/socket.h>
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,
const struct sockaddr *dest_addr, socklen_t addrlen);
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,
struct sockaddr *src_addr, socklen_t *addrlen);
ssize_t sendmsg(int sockfd, const struct msghdr *msg, int flags);
ssize_t recvmsg(int sockfd, struct msghdr *msg, int flags);
read函数是负责从fd中读取内容.当读成功时,read返回实际所读的字节数,如果返回的值是0表示已经读到文件的结束了,小于0表示出现了错误。如果错误为EINTR说明读是由中断引起的,如果是ECONNREST表示网络连接出了问题。
write函数将buf中的nbytes字节内容写入文件描述符fd.成功时返回写的字节数。失败时返回-1,并设置errno变量。 在网络程序中,当我们向套接字文件描述符写时有俩种可能。1)write的返回值大于0,表示写了部分或者是全部的数据。2)返回的值小于0,此时出现了错误。我们要根据错误类型来处理。如果错误为EINTR表示在写的时候出现了中断错误。如果为EPIPE表示网络连接出现了问题(对方已经关闭了连接)。
其它的我就不一一介绍这几对I/O函数了,具体参见man文档或者baidu、Google,下面的例子中将使用到send/recv。
抓取运行在linux上java socket程序的系统调用
为了直观的体现出阻塞IO的过程,这里我们使用是jdk1.4
linux strace命令
Linux命令strace常用来跟踪进程执行的系统调用和所接收的信号,在Linux系统中,用户态和内核态是隔离的,用户态的进程无法直接访问内核态的内存地址,当执行系统调用时,操作系统将会实时中断,打断正在运行的用户态中的进程,切换至内核态,再执行系统调用。strace可以跟踪到一个进程产生的系统调用,包括参数,返回值,执行消耗的时间。
下面我们使用
strace -ff -o java [java文件名]命令来追踪一个java socket程序的系统调用。(这里的两个参数是把每个线程的系统调用打印到out.线程pid的文件内)
这是server端代码
public class BIOServer {
public static void main(String[] args) throws IOException {
//创建一个线程池
ExecutorService threadPool = Executors.newCachedThreadPool();
ServerSocket serverSocket = new ServerSocket(9090);
while (true)
{
//等待一个客户端连接,如果没有客户端连接如果来,就会阻塞
Socket socket = serverSocket.accept();
System.out.println("连接了一个客户端");
//使用一个线程单独处理这个连接
threadPool.execute(()->{
handler(socket);
});
}
}
//处理一个连接
public static void handler(Socket socket)
{
try {
byte[] bytes = new byte[10];
//通过socket获取一个输入流
InputStream is = socket.getInputStream();
while (true)
{
//传统的IO方式,这里没有读到数据就会一直阻塞
//使用字符流读取字符串
InputStreamReader reader = new InputStreamReader(is);
//字符流操作需要缓冲区
BufferedReader bufferedReader = new BufferedReader(reader);
String line=null;
System.out.println(bufferedReader.readLine());
if((line = bufferedReader.readLine())!=null)
{
System.out.println(line);
}
else break;
//;; //直接使用字节流读取字节,会因为切割字符导致中文乱码
// int read = is.read(bytes);
/*为什么-1代表字节流结束?
java整数默认为int,Byte和short都是按int存储或计算,运算后再强制转类型转换
读取字节流时,把byte变成高位全为0的Int类型,不会出现负数,故java可以以-1代表字节流结束
*/
}
} catch (IOException e) {
e.printStackTrace();
}finally {
if(socket!=null)
{
try {
socket.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
}
具体操作与分析
当我们啪唧一下运行这个命令
strace -ff -o java BIOServer
可以看到命令行已经停滞了
当我们再开一个行窗口查看目录
会看到出现了这么多的文件,每一个文件都是一个线程所进行的系统调用
在jdk1.4,我们第一个线程就是主线程
我们打开 out.2300,看看有哪些系统调用
在这个文件中,每一行都是一次系统调用
我们使用/9090 搜索一下我们想看到的
看到了socket,bind,listen这三个系统调用了吧
使用socket得到的3,就是这个socket的文件描述符,使用bind函数,将这个socket绑定到了本机ipv6:9090这个地址
使用listen调用,开启监听
我们再使用
netstat -natp
这个命令检验本机各端口的网络连接情况
可以看到9090这个端口处于了LISTEN状态
再回到out.2300,到最下面,可以看到“accept(3,”,这个系统调用貌似没写完的样子
这是表示调用了这个方法,这个方法并没有放回,进入了阻塞状态
其实就是我们java程序运行到了这里,Socket在等待连接
然后,我们使用nc命令连接这个端口
nc localhost 9090
这个命令会创建一个socket,然后随机分配一个端口,连接上我们指定的socket。这个在上面的connet函数有讲到
再来看netstat
这时候我们看到一个来自53311端口已经连接到了9090,处于连接状态
再看我们的目录
多了一个2386文件,是不是意味着多new了一个线程
我们再看out.2300当时的accept
可以看到当时阻塞的调用现在已经完成了,再看到文件底部
linux 中的clone就是新建一个线程,这里的2386和我们在目录看到的线程ID是不是一样的
最后还是阻塞在accept这里。说明我们的程序执行到了下一次循环
我们看2386文件
看到最后
也是进入了一个阻塞状态,这是我们的程序在等待IO
传统IO方式的缺陷,什么是阻塞,同步,非阻塞,异步?
通过上面的描述,我们对古老的Socket编程有了一定的了解,它的缺陷也非常明显,那就是我们在等待客户端发送消息,如果客户端不发送消息,我们的线程就一直在阻塞当中。这样我们必须为每一个连接单独开辟一个线程,如果有一万个连接,那就有一万个线程,毫无以为对资源的消耗是非常大的
阻塞/非阻塞,同步/异步
- 同步,就当前线程调用一个功能,该功能没有结束前,线程死等结果。
- 异步,就是线程调用一个功能,不需要知道该功能结果,该功能有结果后通知我(回调通知)
- 阻塞,就是调用函数,函数没有接收完数据或者没有得到结果之前,线程失去了占用cpu的权力被挂起了。
- 非阻塞,就是调用函数,函数立即返回。
阻塞/非阻塞是针对函数的
同步和异步是针对调用者的
Socket的阻塞IO与非阻塞IO
在Linux文件结构体中,有f_flags这个字段,表示文件标志,如 O_RDONLY , O_NONBLOCK 以及 O_SYNC 。其中O_NONBLOCK就是用于驱动中进行非阻塞请求的标志。
也就是说,如果我们设置了f_flags这个字段为 O_NONBLOCK,这个文件调用IO函数时是非阻塞的。
我们查看Linux源码,在调用socket.recv接收数据时,最终调用的是recvmsg这个函数
int tcp_recvmsg(struct kiocb *iocb, struct sock *sk, struct msghdr *msg,
size_t len, int nonblock, int flags, int *addr_len){
......
// copied是指向用户空间拷贝了多少字节,即读了多少
int copied;
// target指的是期望多少字节
int target;
// 等效为timo = nonblock ? 0 : sk->sk_rcvtimeo;
timeo = sock_rcvtimeo(sk, nonblock);
......
// 如果设置了MSG_WAITALL标识target=需要读的长度
// 如果未设置,则为最低低水位值
target = sock_rcvlowat(sk, flags & MSG_WAITALL, len);
......
do{
// 表明读到数据
if (copied) {
// 注意,这边只要!timeo,即nonblock设置了就会跳出循环
if (sk->sk_err ||
sk->sk_state == TCP_CLOSE ||
(sk->sk_shutdown & RCV_SHUTDOWN) ||
!timeo ||
signal_pending(current) ||
(flags & MSG_PEEK))
break;
}else{
// 到这里,表明没有读到任何数据
// 且nonblock设置了导致timeo=0,则返回-EAGAIN,符合我们的预期
if (!timeo) {
copied = -EAGAIN;
break;
}
// 这边如果堵到了期望的数据,继续,否则当前进程阻塞在sk_wait_data上
if (copied >= target) {
/* Do not sleep, just process backlog. */
release_sock(sk);
lock_sock(sk);
} else
sk_wait_data(sk, &timeo);
} while (len > 0);
......
return copied
}
上面的逻辑归结起来就是:
(1)在设置了nonblock的时候,如果copied>0,则返回读了多少字节,如果copied=0,则返回-EAGAIN,提示应用重复调用。
(2)如果没有设置nonblock,如果读取的数据>=期望,则返回读取了多少字节。如果没有则用sk_wait_data将当前进程等待。
sk_wait_data,该函数调用schedule_timeout进入睡眠,其进一步调用了schedule函数,首先从运行队列删除,其次加入到等待队列,最后调用和体系结构相关的switch_to宏来完成进程间的切换。
如下图所示:
阻塞后什么时候恢复运行呢
情况1:有对应的网络数据到来
首先我们看下网络分组到来的内核路径,网卡发起中断后调用netif_rx将事件挂入CPU的等待队列,并唤起软中断(soft_irq),再通过linux的软中断机制调用net_rx_action,如下图所示:
情况2:设定的超时时间到来
在前面调用sk_wait_event中调用了schedule_timeout给定了超时时间和回调函数
将socket设置非阻塞模式
我们用fcntl修改socket的阻塞\非阻塞状态。 事实上: fcntl的作用就是将O_NONBLOCK标志位存储在sock_fd对应的filp结构的f_lags里
fcntl(sock_fd, F_SETFL, fdflags | O_NONBLOCK);
|->setfl
static int setfl(int fd, struct file * filp, unsigned long arg) {
......
filp->f_flags = (arg & SETFL_MASK) | (filp->f_flags & ~SETFL_MASK);
......
}
将socket设置为非阻塞状态后,调用IO函数时,如果没有我们期望的数据,则返回-1,而不是将线程进入等待
从非阻塞IO到多路复用器(select,poll,eopll)
之前我们讲过,阻塞IO模式下,我们需要对应连接数量的线程,非常消耗资源,那在非阻塞IO下,我们怎样优化呢?
思路就是一个线程去管理多个连接,既然调用recv,没有数据返回也不会阻塞,那我们可以遍历所有的连接,每个连接调用一次recv函数,看看能不能读到数据,能就进行数据的处理,不能就移动到下一个连接,再次重复这样的操作。
这样只需要一个线程不停的循环,不停的调用系统函数,就可以实现管理多个连接了。
那么非阻塞IO的模型又有什么缺陷呢,我们先简单补充几个概念
操作系统中的用户态,核心态,系统调用,中断
用户态,核心态
所有的现代cpu都提供了几种特权级别,进程可以留驻在某一特权级别
但linux只使用两种不同的状态:用户态和核心态(user mode and kernel mode)。
程序的执行一般是在用户态下执行的,但当程序需要使用操作系统提供的服务时,比如说打开某一设备、创建文件、读写文件等,就需要向操作系统发出调用服务的请求,这就是系统调用。Linux系统有专门的函数库来提供这些请求操作系统服务的入口,这个函数库中包含了操作系统说提供的对外服务的接口。当进程发出系统调用之后,它所处的运行状态就会由用户态变成核心态。但这个时候,进程本身其实并没有做什么事情,是由内核在做相应的操作,去完成进程所提出的这些请求。
用户态和核心态之间的区别:
- 用户态的进程能存取它们自己的指令和数据,但不能存取内核指令和数据(或其他进程的指令和数据)。然而,核心态下的进程能够存取内核和用户地址
- 某些机器指令是特权指令,在用户态下执行特权指令会引起错误
系统调用
就是用户空间应用程序和内核提供的服务之间的一个接口。由于服务是在内核中提供的,我们用户级的应用程序无法执行直接调用;所以在执行系统调用后,程序向cpu发出中断请求,将用户态切换为核心态,再执行内核的服务。
使用中断方法的系统调用的简化流程图
中断
所谓的中断就是在计算机执行程序的过程中,由于出现了某些特殊事情,使得CPU暂停对程序的执行,转而去执行处理这一事件的程序。
等这些特殊事情处理完之后再回去执行之前的程序。中断一般分为三类:
- 由计算机硬件异常或应用程序异常(典型的例子就是除以0)引起的中断,称为内部异常中断;
- 由程序中执行了引起中断的指令而造成的中断,称为软中断(这也是和我们将要说明的系统调用相关的中断);
- 由外部设备请求引起的中断,称为外部中断。简单来说,对中断的理解就是对一些特殊事情的处理。
这些中断的共同特性是:当中断发生时,如果cpu不处于核心态,则发起从用户态到核心态的切换。接下来启动中断服务例程来处理这次中断
多路复用器
非阻塞IO通过进程反复调用IO函数,每次调用都会像操作系统发出一个软中断请求。这样的话,操作系统不断进行中断处理,保护进程现场信息,之后的恢复等等,又大量的浪费了系统资源。
既然多次系统调用浪费了很多资源,那我们是不是要想着只通过一次系统调用,就能获得我们想要的结果呢?
Linux IO多路复用
I/O multiplexing 这里面的 multiplexing 指的其实是在单个线程通过记录跟踪每一个Socket(I/O流)的状态来同时管理多个I/O流. 发明它的原因,是尽量多的提高服务器的吞吐能力。
select,poll
I/O多路复用这个概念被提出来以后, select是第一个实现 (1983 左右在BSD里面实现的)。
假如能够预先传入一个socket列表,如果列表中的socket都没有数据,挂起进程,直到有一个socket收到数据,唤醒进程。这种方法很直接,也是select的设计思想。
select 传递 fd_set* 的指针,fd_set 只是一个包装成 struct 的数组,就是一个 1024bit 的bitmap 而已。由于传入时需要用来标记监控的文件描述符,返回时也要用其标记是否有事件发生,所以每次调用前需要初始化。fd_set 是一个静态的数组,所以 select 支持的文件描述符数量有限。
select出现后,渐渐地暴露出很多的问题:
- select 会修改传入的参数数组,这个对于一个需要调用很多次的函数,是非常不友好的。
- select 如果任何一个sock(I/O stream)出现了数据,select仅仅会返回,但是并不会告诉你是那个sock上有数据,于是你只能自己一个一个的找,10几个sock可能还好,要是几万的sock每次都找一遍。那。。
- select 只能监视1024个链接, linux 定义在头文件中的,参见FD_SETSIZE。
- select 不是线程安全的,如果你把一个sock加入到select, 然后突然另外一个线程发现,尼玛,这个sock不用,要收回。对不起,这个select 不支持的,如果你丧心病狂的竟然关掉这个sock,select的标准行为是。。呃。。不可预测的, 这个可是写在文档中的哦。
于是14年以后(1997年)一帮人又实现了poll, poll 修复了select的很多问题,比如
- poll 传入的相当于一个动态数组(指针 + 元素个数),所以支持的文件描述符数量没有限制
- pollfd 将文件描述符和事件用不同的字段来分离表示,绑定到一个结构体当中。传入时用 events 表示监控的事件,传出时用
revents 表示返回的事件,所以不用 select 一样每次调用初始化一下。
但痛点问题一直存在 - select和poll不能返回具体发送事情的socket,但事件发生后,我们任然需要遍历每一个fd,寻找事件
- 如果在 select 和 poll调用之前,如果没有事件发生。select/poll将阻塞,进程休眠,直到超时、被中断、新的事件来临,内核按文件描述符 fd为依托来处理。此时该 fd 上的 wait queue 会被依次唤醒。通常的实现下,select/poll 并不知道是自己是被哪个 fd唤醒,所以又需要再去遍历一遍所有传入的fd.
epoll(Event Poll)
2002, 大神 Davide Libenzi 实现了epoll。
相对于select,poll,Epoll的特点是
- epoll没有最大并发连接的限制,上限是最大可以打开文件的数目,这个数字一般远大于2048,
一般来说这个数目和系统内存关系很大,具体数目可以cat /proc/sys/fs/file-max察看。 - 效率提升,Epoll最大的优点就在于它只管你“活跃”的连接,而跟连接总数无关,因此在实际的网络环境中,Epoll的效率就会远远高于select和poll。
- 内存拷贝,Epoll在这点上使用了“共享内存”,这个内存拷贝也省略了。
epoll提供的函数
epoll既然是对select和poll的改进,就避免上述的三个缺点。那epoll都是怎么解决的呢?
在此之前,我们先看一下epoll和select和poll的调用接口上的不同,select和poll都只提供了一个函数——select或者poll函数。
而epoll提供了三个函数,epoll_create,epoll_ctl和epoll_wait,
epoll_create是创建一个epoll句柄;
epoll_ctl是注册要监听的事件类型;
epoll_wait则是等待事件的产生。
epoll的执行流程和原理
创建epoll对象
当某个进程调用epoll_create方法时,内核会创建一个eventpoll对象。eventpoll对象也是文件系统中的一员,和socket一样,它也会有等待队列。
维护监视列表
创建epoll对象后,可以用epoll_ctl添加或删除所要监听的socket。以添加socket为例,如下图,如果通过epoll_ctl添加sock1、sock2和sock3的监视,内核会将eventpoll添加到这三个socket的等待队列中。当socket收到数据后,中断程序会操作eventpoll对象,而不是直接操作进程。![在这里插入图片描述![](https://img-blog.csdnimg.cn/2021030819385867.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3dlaXhpbl80NDg5ODk1OQ==,size_16,color_FFFFFF,t_70)
接收数据
当socket收到数据后,中断程序会给eventpoll的“就绪列表”添加socket引用。如下图展示的是sock2和sock3收到数据后,中断程序让rdlist引用这两个socket。
eventpoll对象相当于是socket和进程之间的中介,socket的数据接收并不直接影响进程,而是通过改变eventpoll的就绪列表来改变进程状态。
当程序执行到epoll_wait时,如果rdlist已经引用了socket,那么epoll_wait直接返回,如果rdlist为空,阻塞进程。
阻塞和唤醒进程
假设计算机中正在运行进程A和进程B,在某时刻进程A运行到了epoll_wait语句。如下图所示,内核会将进程A放入eventpoll的等待队列中,阻塞进程。
当socket接收到数据,中断程序一方面修改rdlist,另一方面唤醒eventpoll等待队列中的进程,进程A再次进入运行状态(如下图)。也因为rdlist的存在,进程A可以知道哪些socket发生了变化。
eventpoll的数据结构
如下图所示,eventpoll包含了lock、mtx、wq(等待队列)、rdlist等成员。rdlist和rbr是我们所关心的。
就绪列表
就绪列表引用着就绪的socket,所以它应能够快速的插入数据。
程序可能随时调用epoll_ctl添加监视socket,也可能随时删除。当删除时,若该socket已经存放在就绪列表中,它也应该被移除。
所以就绪列表应是一种能够快速插入和删除的数据结构。双向链表就是这样一种数据结构,epoll使用双向链表来实现就绪队列(对应上图的rdllist)。
索引结构
既然epoll将“维护监视队列”和“进程阻塞”分离,也意味着需要有个数据结构来保存监视的socket。至少要方便的添加和移除,还要便于搜索,以避免重复添加。红黑树是一种自平衡二叉查找树,搜索、插入和删除时间复杂度都是O(log(N)),效率较好。epoll使用了红黑树作为索引结构(对应上图的rbr)。
ps:因为操作系统要兼顾多种功能,以及由更多需要保存的数据,rdlist并非直接引用socket,而是通过epitem间接引用,红黑树的节点也是epitem对象。同样,文件系统也并非直接引用着socket。为方便理解,本文中省略了一些间接结构。
epoll小总结
epoll_create函数创建一个epoll对象
epoll对象中,维护了一个红黑树,头节点rbr,红黑树每个节点存放着我们需要监视的socket,使用epoll_ctl函数可以新插入一个socket,每当插入socket时,将epoll对象存入这个socket的等待队列中。
epoll对象维护了一个双链表rdlist,存放有事件发生的socket。
当应用程序执行epoll_wait,如果rdlist中存有socket,那直接返回所有socket的fds,如果没有,则阻塞该进程。
每当数据到来时,网卡向cpu发出一个中断请求,随后开始的中断处理程序中,数据流向指定的socket,然后将此socket添加到它等待队列中epoll对象中的rdlist中。并唤醒epoll等待队列中的进程,使它调用的epoll_wait方法返回。
(注意,提到的两个等待队列,一个socket的等待队列,存放epoll对象,另一个是epoll对象中的等待队列,存放进程)
java.nio
再看一段NIO实现的服务器,是不是有种通透的感觉
public class NIOServer {
public static void main(String[] args) throws IOException {
//得到一个Selector
Selector selector = Selector.open();
//new一个ServerSocket
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
//绑定一个端口
serverSocketChannel.socket().bind(new InetSocketAddress(6666));
//设置将这个socket为非阻塞IO模式
serverSocketChannel.configureBlocking(false);
//把channel注册到selector,监听的事件为OP_ACCEPT即连接事件
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
while (true)
{
//这个select相当于epoll_wait,可以设置超时时间,超过1秒没有即可停止阻塞立刻返回
if(selector.select(1000)==0)
{
System.out.println("服务器等待了一秒,无连接");
continue;
}
//如果selector.select返回大于0,就获取到相关的selectionkey集合
//1.如果返回的>0 表示已经h获取到关注的事件
//2.selector.selectedKeys()返回关注事件的集合
//通过selectionkeys反向获取通道
Set<SelectionKey> selectionKeys = selector.selectedKeys();//返回有状态的fd集合
Iterator<SelectionKey> keyIterator = selectionKeys.iterator();
while (keyIterator.hasNext())
{
//获得SelectionKey
SelectionKey selectionKey = keyIterator.next();
//获取key对应通道发生的事件
if(selectionKey.isAcceptable())
{
//事件是连接事件,那我们调用accept方法的时候就会立刻获取一个连接
//也说明epoll只返回有事件的socket,应用程序再做逻辑处理
SocketChannel socketChannel = serverSocketChannel.accept();
socketChannel.configureBlocking(false);
//把这个channel注册到selector,关注事情为OP_READ,同时给这个channel关联一个BUFF
socketChannel.register(selector,SelectionKey.OP_READ, ByteBuffer.allocate(1024));
}
if(selectionKey.isReadable())
{
//通过key反向获得Channel
SocketChannel socketChannel = (SocketChannel) selectionKey.channel();
//获得和这个Channel关联的buffer
ByteBuffer byteBuffer = (ByteBuffer)selectionKey.attachment();
socketChannel.read(byteBuffer);
System.out.println("客户端发来的"+new String(byteBuffer.array()));
}
//手动从集合中移动,避免重复处理
keyIterator.remove();//因为调用select方法会是新增有状态fd到SelectionKey集合,不是重新建一个集合
}
}
}
}