从Linux内核开始,带你建立一套完成的,基础的Java网络IO知识体系

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函数的三个参数分别为

  1. domain:即协议域,又称为协议族(family)。常用的协议族有,AF_INET、AF_INET6、AF_LOCAL(或称AF_UNIX,Unix域socket)、AF_ROUTE等等。协议族决定了socket的地址类型,在通信中必须采用对应的地址,如AF_INET决定了要用ipv4地址(32位的)与端口号(16位的)的组合、AF_UNIX决定了要用一个绝对路径名作为地址。
  2. type:指定socket类型。常用的socket类型有,SOCK_STREAM、SOCK_DGRAM、SOCK_RAW、SOCK_PACKET、SOCK_SEQPACKET等等。
  3. 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);

函数的三个参数分别为:

  1. sockfd:即socket描述字,它是通过socket()函数创建了,唯一标识一个socket。bind()函数就是将给这个描述字绑定一个名字。
  2. 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 */  };
  1. 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编程有了一定的了解,它的缺陷也非常明显,那就是我们在等待客户端发送消息,如果客户端不发送消息,我们的线程就一直在阻塞当中。这样我们必须为每一个连接单独开辟一个线程,如果有一万个连接,那就有一万个线程,毫无以为对资源的消耗是非常大的

阻塞/非阻塞,同步/异步

  1. 同步,就当前线程调用一个功能,该功能没有结束前,线程死等结果。
  2. 异步,就是线程调用一个功能,不需要知道该功能结果,该功能有结果后通知我(回调通知)
  3. 阻塞,就是调用函数,函数没有接收完数据或者没有得到结果之前,线程失去了占用cpu的权力被挂起了。
  4. 非阻塞,就是调用函数,函数立即返回。
    阻塞/非阻塞是针对函数的
    同步和异步是针对调用者的

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,如下图所示:

low_recv
情况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系统有专门的函数库来提供这些请求操作系统服务的入口,这个函数库中包含了操作系统说提供的对外服务的接口。当进程发出系统调用之后,它所处的运行状态就会由用户态变成核心态。但这个时候,进程本身其实并没有做什么事情,是由内核在做相应的操作,去完成进程所提出的这些请求。

用户态和核心态之间的区别:
  1. 用户态的进程能存取它们自己的指令和数据,但不能存取内核指令和数据(或其他进程的指令和数据)。然而,核心态下的进程能够存取内核和用户地址
  2. 某些机器指令是特权指令,在用户态下执行特权指令会引起错误

系统调用

就是用户空间应用程序和内核提供的服务之间的一个接口。由于服务是在内核中提供的,我们用户级的应用程序无法执行直接调用;所以在执行系统调用后,程序向cpu发出中断请求,将用户态切换为核心态,再执行内核的服务。
使用中断方法的系统调用的简化流程图
在这里插入图片描述

中断

所谓的中断就是在计算机执行程序的过程中,由于出现了某些特殊事情,使得CPU暂停对程序的执行,转而去执行处理这一事件的程序。
等这些特殊事情处理完之后再回去执行之前的程序。中断一般分为三类:

  1. 由计算机硬件异常或应用程序异常(典型的例子就是除以0)引起的中断,称为内部异常中断;
  2. 由程序中执行了引起中断的指令而造成的中断,称为软中断(这也是和我们将要说明的系统调用相关的中断);
  3. 由外部设备请求引起的中断,称为外部中断。简单来说,对中断的理解就是对一些特殊事情的处理。

这些中断的共同特性是:当中断发生时,如果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出现后,渐渐地暴露出很多的问题:

  1. select 会修改传入的参数数组,这个对于一个需要调用很多次的函数,是非常不友好的。
  2. select 如果任何一个sock(I/O stream)出现了数据,select仅仅会返回,但是并不会告诉你是那个sock上有数据,于是你只能自己一个一个的找,10几个sock可能还好,要是几万的sock每次都找一遍。那。。
  3. select 只能监视1024个链接, linux 定义在头文件中的,参见FD_SETSIZE。
  4. select 不是线程安全的,如果你把一个sock加入到select, 然后突然另外一个线程发现,尼玛,这个sock不用,要收回。对不起,这个select 不支持的,如果你丧心病狂的竟然关掉这个sock,select的标准行为是。。呃。。不可预测的, 这个可是写在文档中的哦。

于是14年以后(1997年)一帮人又实现了poll, poll 修复了select的很多问题,比如

  1. poll 传入的相当于一个动态数组(指针 + 元素个数),所以支持的文件描述符数量没有限制
  2. pollfd 将文件描述符和事件用不同的字段来分离表示,绑定到一个结构体当中。传入时用 events 表示监控的事件,传出时用
    revents 表示返回的事件,所以不用 select 一样每次调用初始化一下。
    但痛点问题一直存在
  3. select和poll不能返回具体发送事情的socket,但事件发生后,我们任然需要遍历每一个fd,寻找事件
  4. 如果在 select 和 poll调用之前,如果没有事件发生。select/poll将阻塞,进程休眠,直到超时、被中断、新的事件来临,内核按文件描述符 fd为依托来处理。此时该 fd 上的 wait queue 会被依次唤醒。通常的实现下,select/poll 并不知道是自己是被哪个 fd唤醒,所以又需要再去遍历一遍所有传入的fd.

epoll(Event Poll)

2002, 大神 Davide Libenzi 实现了epoll。
相对于select,poll,Epoll的特点是

  1. epoll没有最大并发连接的限制,上限是最大可以打开文件的数目,这个数字一般远大于2048,
    一般来说这个数目和系统内存关系很大,具体数目可以cat /proc/sys/fs/file-max察看。
  2. 效率提升,Epoll最大的优点就在于它只管你“活跃”的连接,而跟连接总数无关,因此在实际的网络环境中,Epoll的效率就会远远高于select和poll。
  3. 内存拷贝,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集合,不是重新建一个集合
            }
        }
    }
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值