高级IO
- 前言
- 正式开始
- 前面的IO函数简单过一遍
- 什么叫做低效的IO
- 钓鱼的例子
- 同步IO和异步IO
- 五种IO模型
- 阻塞IO
- 非阻塞IO
- 信号驱动
- 多路转接
- 异步IO
- 小结
- 代码演示
- 非阻塞IO
- 多路转接
- select介绍
- 简易select服务器
- timeout 为 nullptr
- timeout 为 {0, 0}
- timeout 为 {5, 0}
- 调用accept
- select编写代码的一般流程
- 重写
- 完整代码
- select优缺点
- poll
- poll的优缺点
前言
本篇主要讲解:
- 五种IO模型的介绍
- 重点讲解多路转接
- select服务器的编写
- poll服务器的编写
关于多路转接的epoll我会在下一篇详细讲解。
前面我一直在讲网络通信,从创建套接字就可看到网络通信的就是IO,发送方能发也能收,接收方也是能发也能收,站在网络角度来看就是机器把数据扔到了网络里面,站在计算机体系结构角度来看就
是把数据把内存扔到网卡,不管怎么理解,都是IO。
正式开始
前面的IO函数简单过一遍
前面文件部分讲过的IO都是文件IO,单机的,打开文件,将数据从磁盘读到os,再从os将数据拷到用户缓冲区,各设备离的都非常近,在网络中,两台主机相隔千里之外,IO效率一定是要比单机来说低不少的。
IO问什么低效?
read、recv、recvfrom、write、send、sendto这样的IO函数本质上都是一些拷贝函数,都是在用户和内核之间拷贝数据,不过毕竟是从内存中直接拷贝的,效率还算OK。
以read为例,当我们进行read/recv的时候,如果底层没有数据,read/recv会怎么办做?有数据又会怎么做?
没有数据,read/recv进程就会阻塞,也就是让进程等。
如果有数据就直接进行拷贝。
⇒ 所以IO就是 等 + 数据拷贝。
等就是等IO类事件就绪。读就是底层有数据,写就是底层有空间。
write也是一样的,缓冲区满了就不让拷贝(等),没满就拷贝,所以IO必须经历的两部就是等和数据拷贝。
看图:
如果进程想要访问磁盘上的文件,那就得先打开这个文件,而文件 = 内容 + 属性,所以打开文件后,os要为文件创建相应的struct FILE结构体以维护文件的属性,也就是在内存中维护,而内存是惰性加载的,不会说将文件中的所有数据全部加载完,因为很多数据不一定能用上,os可能会对文件预加载,也就是先加载一部分,当进程想要修改文件中的内容时,就会先将需要的数据加载到内存里:
此时就是进程先调用的IO类型的函数想要访问文件中的数据,然后os才会做加载的这一步的,也就是os加载之前进程就已经开始调用IO类函数了。
那么os在加载文件的内容时,进程在干嘛?
就是在等。
IO = 等 + 拷贝。上面os在加载的时候,就是等,此刻进程是处于阻塞状态的。
那么拷贝呢?
就是加载完毕之后。进程就会被os唤醒,然后对os加载好的数据进行后续操作。
无论是网络还是单机,只要是访问磁盘、键盘、网卡等等外设,就一定是等 + 数据拷贝。
想一想scanf运行起来之后,为什么会卡在命令行等你输入,其实就是在等待标准输入。cin也是同理,像这样的函数都是在等数据就绪后再将数据从外设搬到内存os的缓冲区中,再从os搬到应用层,这就是数据拷贝。
所以recv、read、send、write等函数看起来是在发送和接收,其实都是在等IO类事件就绪,然后再发起拷贝,拷贝时无非就是从内核到用户或从用户到内核,所以这些函数不是用户直接与硬件进行读写,而是用户和内核之间的“交流”,交流完毕后,os再做后续的事情,比如说将修改后的数据写回磁盘。
在os视角来看,这些函数会让进程阻塞,在IO视角来看就是让进程在等。
什么叫做低效的IO
网络里面谈IO是因为报文从A主机发送到B主机,中间的发送时间会很长,所以网络通信时调用read、recv等函数就要做IO,这样就会花费大量的时间在等上,如何提高IO的效率呢?只要想办法在单位时间内让等的比重变得越低IO的效率就会越高。
单位时间内让等的比重变低,如何做到呢?
前面大佬们已经对于IO进行了深刻研究,总结出来了五中IO模型,这篇重点要讲的就是这五中IO模型。
先说说都是啥:
- 阻塞IO
- 非阻塞IO
- 信号驱动
- 多路转接(多路复用)
- 异步IO
不过这里先不说这五种IO模型的细节,我先通过一个生活中的例子来帮大家理解理解。
钓鱼的例子
钓鱼应该都见过吧。这里不说打窝这样的细节,简单一点。
就直接说成等 + 鱼上钩的收杆(后面直接说钓,也就是等 + 钓)。就像mc中的钓鱼一样。
什么场景下会说一个人钓鱼的效率非常高呢?
一个人大半天都没有鱼咬钩,一直在等。
另一个人一直是上钩,不带停的。
很明显,第二个人效率高,所以只要单位时间内等的比重非常低,这个人钓鱼的效率就非常高。
再来介绍个东西,鱼漂,钓鱼佬应该很熟悉,但是没钓过鱼的同学可能很陌生,看图:
钓鱼的时候,鱼漂能够反映出鱼咬钩的讯息。
假如说现在有五个人去钓鱼。
张三钓鱼的时候死死盯住鱼漂,啥也不干,非常专注,鱼漂不动他不动。
李四耐不住性子,看一会手机再看鱼漂有反应没,没反应就接着看手机。
王五拿了个铃铛,挂在鱼杆后面,一直在玩手机,铃铛一响就赶紧收杆。
赵六是个方圆五公里内的富二代,一下子拿了100支鱼竿,安置好后就来回检测哪只哪支鱼竿有鱼咬钩。
田七是个大老板,但是最近想吃鱼了(不是高启强😅),但是他比较忙,于是给了他手下小王一个桶,让小王去钓,等把桶钓满了再给他打电话,田七再去取。
那么上面这五种情况就对应了五中IO模型。
张三就是阻塞式IO,李四就是非阻塞式IO,王五就是信号驱动,赵六就是多路复用,田七就是异步IO。
那么谁的钓鱼效率更高呢?
赵六。
为啥呢?
站在鱼的角度,鱼脑袋上有104个诱饵(这里认为鱼一定会咬钩,不考虑打窝的情况,诱饵都一样且在某个区域中均匀分布),所以对于每个鱼竿来说,上鱼的概率都是1/104,但是赵六这个人的概率是100/104,而其他人都是1/104,所以单位时间内赵六等的比重是非常低的。
同步IO和异步IO
上面的人就对应的是进程或者线程,进程或线程只要参与了IO就称为同步IO。
什么叫参与IO呢?
就是要么参与了等,要么参与了拷贝,要么同时都参与。
只要参与了就叫做同步IO。
田七既没有等也没有钓(拷贝),所以田七是异步IO。
再来看看王五是同步IO吗?
前面我讲信号的时候说过信号的产生是异步的,但是王五是参与了IO的,他在等也在等鱼上钩,而且也是亲自钓的,而不是像田七那样直接不在场。也就是说数据没有就绪就先忙着自己的事情,但是一旦就绪了自己就将数据从内核拷贝到用户空间,所以是参与了IO的。这里的信号驱动,和单纯的信号产生有些不同,就在于IO这里有后续的拷贝动作,谈的不是信号的发送是异步的,谈的是信号发送之后要参与IO,还是同步的。
【注】这里信号驱动其实是有争议的,有的人说是同步IO,有的人说是异步IO,但我这里按照同步来说。
张三和李四的阻塞IO和非阻塞IO有什么区别?
都是同步IO,IO = 等 + 拷贝,都要亲手钓,这里没什么区别,主要的区别是在等上,张三是阻塞的等,李四是非阻塞的等。
阻塞式等,就是进程/线程检测某个文件描述符上是否有事件就绪,没有事件就绪就阻塞,也就是将进程的PCB放到等待队列中,后面的工作就由os来做了,并不是进程/线程在检测,而是os在做检测,当检测到对应文件描述符数据就绪了就把对应进程唤醒,并将PCB放到运行队列中,进程/线程阻塞期间什么也做不了,状态为非R。
非阻塞等就是事件没有就绪时os不会将进程/线程的PCB放到等待队列中,而是继续让它执行后续代码,我们经常是写个循环,然后其中调用IO函数,如果数据没有就绪就循环回去执行IO前面的代码,然后再次执行到IO函数,然后再次检测是否就绪,此即轮询。也就是非阻塞IO的非轮询检测。
前面多线程间的同步和这里的IO同步不是一个东西,多线程的同步背景是线程,是多线程执行流在协同工作,而这里的IO同步背景是IO,所以网上看计算机中的同步相关的资料时一定要确定是什么同步。
这里就带各位简单的了解了五中IO模型,下面来细说说。
主要讲一下阻塞、非阻塞和多路转接。信号驱动用的最少,异步IO在网络库或者IO库中是有的,但是很多公司都不太想用,因为可能会导致IO逻辑变的很混乱,但也不是不用,只是用的少。
五种IO模型
张三、李四这些人对应的就是一个进程或线程,鱼竿对应的就是文件描述符,鱼漂对应文件描述符是否有时间就绪,鱼即数据,鱼所在的水域就是缓冲区。
先简单过一遍,然后再写代码。
阻塞IO
阻塞IO: 在内核将数据准备好之前, 系统调用会一直等待. 所有的套接字, 默认都是阻塞方式。
阻塞IO是最常见的IO模型:
左边对应用户空间,右边对应os的内核空间。
上面用户调用recv这样的系统级别的IO函数,就会进入阻塞状态,后面的工作就是os在做了,用户啥也做不了,数据拷贝好后才能做后续工作。
非阻塞IO
非阻塞IO: 如果内核还未将数据准备好, 系统调用仍然会直接返回, 并且返回EWOULDBLOCK错误码.
这里的EWOULDBLOCK错误码不写代码感受不出来,等会写代码的时候就懂了。
非阻塞IO往往需要程序员循环的方式反复尝试读写文件描述符, 这个过程称为轮询. 这对CPU来说是较大的浪费, 一般只有特定场景下才使用
这里会对数据是否准备好做轮询检测,如果没有准备好就先干自己的事情,干一会后再检查一下,如果还没好就继续做自己的事情,直到某一次检测数据准备好了,就会对数据进行拷贝。
信号驱动
信号驱动IO: 内核将数据准备好的时候, 使用SIGIO信号通知应用程序进行IO操作。
来看看这个信号:
流程:
这里涉及到了信号的相关操作,如果你不懂信号,可以看看我这一篇:信号详解
开始的时候对SIGIO信号自定义处理,定义好信号的捕捉方法sigaction,当接收到SIGIO信号的时候就去执行sigaction函数,sigaction函数中一定是会调用recv这样的IO函数的。
这里就是由争议的地方,信号。但是进程不是在等信号,而是在等数据就绪,但等数据的同时又能自由的做自己的事情,SIGIO到来的时候就去处理SIGIO。不要深究这些东西,没有太大意义。会用就行。
多路转接
先来看流程图:
IO多路转接: 虽然从流程图上看起来和阻塞IO类似. 实际上最核心在于IO多路转接能够同时等待多个文件描述符的就绪状态。
支持多路转接的OS要提供独有的接口,一个接口专门负责一个等的动作。
而select就是专门负责等多个文件描述符的,不会进行拷贝,这个接口可以向其中添加很多货文件描述符,也就是一次可以等多个文件描述符上的数据准备就绪,多个文件描述符随时有可能准备就绪,如果有文件描述符准备就绪,select就要把准备就绪的文件反应给进程,让进程调用recv等函数进行读取。
所以这里等的时候能并行一块等,读取的时候只能串行一个一个来读,和赵六钓鱼一样的,一下子把100个鱼竿安好(并行等),然后有杆钓上鱼了就去哪个杆(串行)。
select和IO函数各司其职,select这种类似的多路转接的接口只负责等,当数据就绪时就让上层的IO类接口只进行拷贝,此时上层的IO函数就不会出现导致进程阻塞,因为上层的select已经告诉了进程底层有数据了,本次调用recv这样的IO函数绝对不会阻塞,理想情况下只需要拷贝。
当然这里光说的话有点难懂,后面用代码演示就好理解了。
异步IO
异步IO: 由内核在数据拷贝完成时, 通知应用程序(而信号驱动是告诉应用程序何时可以开始拷贝数据).
aio_read这样的函数一般都是要先给os一端用户级的缓冲区,后续就不需要再等了,不用调用recv之类的函数,os自动帮你把数据拷到你给的缓冲区中,拷贝完后就给你通知拷贝完了。
田七(进程)给小王(os)一个桶(用户级缓冲区),小王去钓(os办事),田七办自己的事,桶钓满(拷贝好了)了通知田七。
注意这里的通知和前面的信号驱动不一样的,前面的信号驱动是要进程自己调用recv拷贝数据的,而这里是os直接帮进程把数据就拷贝好了。
小结
任何IO过程中, 都包含两个步骤. 第一是等待, 第二是拷贝. 而且在实际的应用场景中, 等待消耗的时间往往都远远高于拷贝的时间. 让IO更高效, 最核心的办法就是让等待的时间尽量少.
mmap也是一个高级的IO,想了解的同学请自行查资料看看。
代码演示
非阻塞IO
前面我所有的博客都是阻塞式的IO,想要变成非阻塞,就需要在打开文件的时候就设置打开文件的选项O_NONBLOCK。
还有创建套接字也一样可以设置:
设置了之后就文件就具有了非阻塞的属性。
所以想要让文件描述符在读写的时候能进行非阻塞读写,就要进行属性设置,打开文件时就设定。(无论是创建套接字还是普通的文件)。
但是这样有点麻烦,我们可以用同一的方式来进行非阻塞的设置,即fcntl函数:
参数fd就是文件描述符,cmd就是你要选择哪种功能,后面的…表示这是可变参数。
传入的cmd的值不同, 后面追加的参数也不相同。
fcntl函数有5种功能:
- 复制一个现有的描述符(cmd=F_DUPFD).
- 获得/设置文件描述符标记(cmd=F_GETFD或F_SETFD).
- 获得/设置文件状态标记(cmd=F_GETFL或F_SETFL).
- 获得/设置异步I/O所有权(cmd=F_GETOWN或F_SETOWN).
- 获得/设置记录锁(cmd=F_GETLK,F_SETLK或F_SETLKW).
这里我们要改的是文件的状态,也就是阻塞还是非阻塞,所以等会用的就是第三行的F_GETFL和F_SETFL。F_GETFL是获取状态,F_SETFL是设置状态。
这里无论是普通文件、管道文件还是套接字文件,只要是文件描述符就行,fcntl都可以将对应文件状态设置成非阻塞模式。
函数返回值:
下面来写写代码。
先来看一个基本的阻塞IO:
上面0就是标准输入,这就不细讲了,最开始给的那篇博客中有。
此时运行起来就会阻塞在这里:
因为一直在等待键盘对应文件的资源就绪,输入了之后才等于是资源就绪了:
然后再来搞一下非阻塞,简单封装一下fcntl:
这里就是对F_GETFL和F_SETFL的使用,先用F_GETFL获取原先文件描述符对应文件的状态,然后再用F_SETFL来设置文件状态,就是再添加一个非阻塞的标志位,像位图一样,用一个 | 就行,在原始的f1标志位上新增一个非阻塞的标志位,不影响其他标志位。
在前面对0设置非阻塞:
运行:
一直在打印err。
不过打印太快了,加一个sleep控制一下:
这样打印的慢一点:
我输入后也可以读取:
但是用起来有点怪,因为打印的时候是往屏幕上打的,输入的时候也是要在屏幕上显示。
所以非阻塞的时候是IO函数是以出错的形式返回,告知上层数据没有就绪,如果数据就绪了话,正常读取就行。那么我们如何甄别是真的出错了还因为数据没有准备就绪呢?
出错不仅仅通过read的返回值判断的,出错了系统还会设置errno,所以还可以通过errno来判断是什么问题。
运行:
所以如果read失败的errno是11,就代表其实read没出错,不过是底层数据没有就绪,所以 s <= 0的时候可以再判断一下errno是否等于11。不过可以不用数字,刚刚再介绍非阻塞的时候说了一个EWOULDBLOCK字段,这个字段的值其实就是11:
很多地方判断errno是否是11都是这样用的:
send、recv等IO函数非阻塞的时候也会返回这个EWOULDBLOCK,但是我感觉这两个一个就够了,如果有懂的老铁可以在评论给我解答一下吗,谢谢了。
运行:
还有一个很重要的字段,EINTER,就是interrupt,被打断了,用于在等的阶段被其他东西打断了,比如说进程/线程可能收到某个信号,此时os就会将进程/线程唤醒去处理信号,可是处理信号了就不回来了,此时errno就会被设置成EINTER,表示中断了,所以也可以再添加一个:
相当于是IO没读完就被中断了,需要重新读取。所以二者都是正常情况,直接continue就行。但我这里整不出来相关的场景,就不演示了。
多路转接
select用的稍微多一点,但是工作中也不会直接从0开始写,不过这里还是要写写这个了解一下过程,方便理解。
select是Linux提供的多路转接方案中的一种,根据前面所讲的赵六,一次可以等多个文件描述符,那么select功能就有两个:
- 帮助用户进行一次等待多个文件fd
- 当哪些文件fd就绪了,select就要通知用户对应就绪的fd有哪些
然后用户再调用recv/read这样的函数进行数据读取,记住多路转接是为我们提供一个更高效的等待方案,一次可以等多个文件描述符。
认识一下select接口:
select介绍
展开来看:
select作用就是让os注意多个文件描述符,如果有文件描述符就绪了就告诉用户哪个就绪了。
挑着讲:
第一个参数nfds是你让os注意的最大文件描述符 + 1。
- 比如说最大文件描述符的值为5,那么nfds就是6(0、1、2、3、4、5正好六个)
返回值就是就绪的fd的个数,有3个就绪了就是3,有5个就绪了就是5,1个就绪了就是1,至少有一个fd数据就绪/空间就绪了就可以返回了。
后四个参数都是输入输出型参数,先来说最后一个timeout,其类型为timeval的结构体:
其中tv_sec单位是以秒,tv_usec单位是微秒。
这个结构体可以配合着gettimeofday来用:
这个函数可以获取当前系统的时间戳,传一个timeval结构体来获取参数为tz区域的时间,tz给空就是本地的时间。带着C语言中的time函数演示一下:
打印出来前面秒级别的和C中的time一样,.后面的是微秒级别的
再说回最后一个参数timeout
这个参数可以设置等待多个参数的策略,有三种:
- 阻塞式IO,timeout设置为空。
- 非阻塞式IO,timeout设置为{0, 0}。
- timeout规定时间内阻塞,时间一到立马返回,比如说设置为{5, 0},就是5s。5s是输入性参数的含义,还有输出型参数的含义:若等待时间内有fd就绪,timeout就表示剩余多少时间,比如说设置5s,2s时有文件就绪,那么time此时就是{3, 0},也就是剩余三秒。
中间三个参数:
- 三个参数,分别对应有文件的读事件,写事件和异常事件,类型都是fd_set,是一个系统提供的类型,底层是位图,每一个比特位表示一个文件描述符的状态,等会细讲。
- 作为输入的时候是用户告诉内核,你要帮我关心哪个/哪些fd上的那种事件。作为输出时,就是内核告诉用户,我所关心的fd中,哪些fd上的哪类时间已经就绪了。
- 先来说说fd_set:
系统是用一个定长的数组来表示的位图。结构体是由系统提供的,用户不能直接对其进行按位与、按位或等操作,而是用系统提供的方法:
这四个函数作用分别是:CLR清除一个文件描述符,ISSET判断某个文件描述符在不在位图中,SET设置一个文件描述符,ZERO将文件描述符清空。看一下系统中的fd_set最多能容纳多少个文件描述符:
这里乘以8是因为sizeof求的是字节数,而位图是看有多少比特位的,一个字节8位:
.
.
再来看这三个参数
三个参数在用法上都是一样的,我就挑readfds来说,就是读文件描述符集。
a. 作为输入型参数时,是用户通知内核,我的比特位中,比特位的位置就表示文件描述符的值,比特位的内容表示是否关心,比如说 0000 1010,左边是高位,右边是低位,低位从0开始,这里就是指0 ~ 7的文件描述符,这里就表示0、2、4、5、6、7号文件描述符不关心读,1、3关心读。
b. 输出的时候内核告诉用户,用户你让我关心的多个fd有结果了,比特位的位置依旧表示文件描述符的值,比特位的内容表示是否就绪,比如说刚刚让os关心1号和3号,如果只有三号就绪,返回的就是0000 1000,表示用户可以直接读取3号而不会发送阻塞。
故用户和内核都修改同一个位图结构,所以这个参数用一次之后一定需要进行重新设定,剩下的三个一样,如果既关心读又关心写,就可以同时把文件描述符加到其中,虽然这样的情况很少,下面就来写写代码,等会肯定是写一会就写不下去了,因为还没说select的一般的编写代码的模式(直接将模式的话不能理解,得先见见select怎么用)。
简易select服务器
关于怎么写服务器不再详谈,我前面的博客中有,不懂的同学请自行查看。
我这里就直接用我前面封装好的套接字接口来写了,两个现成的文件:
打印日志:
#pragma once
#include <cstdio>
#include <cstring>
#include <ctime>
#include <cstdarg>
#include <unistd.h>
#include <vector>
// 文件名
#define _F __FILE__
// 所在行
#define _L __LINE__
enum level
{
DEBUG, // 0
NORMAL, // 1
WARING, // 2
ERROR, // 3
FATAL // 4
};
std::vector<const char*> gLevelMap = {
"DEBUG",
"NORMAL",
"WARING",
"ERROR",
"FATAL"
};
#define FILE_NAME "./log.txt"
void LogMessage(int level, const char* file, int line, const char* format, ...)
{
#ifdef NO_DEBUG
if(level == DEBUG) return;
#endif
// 固定格式
char FixBuffer[512];
time_t tm = time(nullptr);
// 日志级别 时间 哪一个文件 哪一行
snprintf(FixBuffer, sizeof(FixBuffer), \
"<%s>==[file->%s] [line->%d] ----------------------------------- time:: %s", gLevelMap[level], file, line, ctime(&tm));
// 用户自定义格式
char DefBuffer[512];
va_list args; // 定义一个可变参数
va_start(args, format); // 用format初始化可变参数
vsnprintf(DefBuffer, sizeof DefBuffer, format, args); // 将可变参数格式化打印到DefBuffer中
va_end(args); // 销毁可变参数
// 往显示器打
printf("%s\t=\n\t=> %s\n\n\n", FixBuffer, DefBuffer);
// 往文件中打
// FILE* pf = fopen(FILE_NAME, "a");
// fprintf(pf, "%s\t==> %s\n\n\n", FixBuffer, DefBuffer);
// fclose(pf);
}
套接字相关:
#pragma once
#include "LogMessage.hpp"
#include <iostream>
#include <string>
#include <memory>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <unistd.h>
// 对套接字相关的接口进行封装
class Sock
{
private:
static const int gBackLog = 20;
public:
// 1. 创建套接字
static int Socket()
{
/*先AF_INET确定网络通信*/ /*这里用的是TCP,所以用SOCK_STREAM*/
int listenSock = socket(AF_INET, SOCK_STREAM, 0);
// 创建失败返回-1
if(listenSock == -1)
{
LogMessage(FATAL, _F, _L, "server create socket fail");
exit(2);
}
LogMessage(DEBUG, _F, _L, "server create socket success, listen sock::%d", listenSock);
// 创建成功
return listenSock;
}
// 2. bind 绑定IP和port
static void Bind(int listenSock, uint16_t port, const std::string& ip = "0.0.0.0")
{
sockaddr_in local; // 各个字段填充
memset(&local, 0, sizeof(local));
// 若为空字符串就绑定当前主机所有IP
local.sin_addr.s_addr = inet_addr(ip.c_str());
local.sin_port = htons(port);
local.sin_family = AF_INET;
/*填充好了绑定*/
if(bind(listenSock, reinterpret_cast<sockaddr*>(&local), sizeof(local)) < 0)
{
LogMessage(FATAL, _F, _L, "server bind IP+port fail :: %d:%s", errno, strerror(errno));
exit(3);
}
LogMessage(DEBUG, _F, _L, "server bind IP+port success");
}
// 3. listen为套接字设置监听状态
static void Listen(int listenSock)
{
if(listen(listenSock, gBackLog/*后面再详谈listen第二个参数*/) < 0)
{
LogMessage(FATAL, _F, _L, "srever listen fail");
exit(4);
}
LogMessage(NORMAL, _F, _L, "server init success");
}
// 4.accept接收连接 输出型参数,返回客户端的IP + port
static int Accept(int listenSock, std::string &clientIp, uint16_t &clientPort)
{
/*客户端相关字段*/
sockaddr_in clientMessage;
socklen_t clientLen = sizeof(clientMessage);
memset(&clientMessage, 0, clientLen);
// 接收连接
int serverSock = accept(listenSock, reinterpret_cast<sockaddr*>(&clientMessage), &clientLen);
// 对端的IP和port信息
clientIp = inet_ntoa(clientMessage.sin_addr);
clientPort = ntohs(clientMessage.sin_port);
if(serverSock < 0)
{
// 这里没连接上不能说直接退出,就像张三没有揽到某个客人餐馆就不干了,所以日志等级为ERROR
LogMessage(ERROR, _F, _L, "server accept connection fail");
return -1;
}
else
{
LogMessage(NORMAL, _F, _L, "server accept connection success ::[%s:%d] server sock::%d", \
clientIp.c_str(), clientPort,serverSock);
}
return serverSock;
}
};
然后对服务器简单封装一下:
这里还剩下一步accept就可以进行通信了,但是有个问题,这一篇要讲高级IO,如果直接accept就会导致服务器阻塞在accept处等待连接。想要高级一点,那就不要阻塞,用select来进行多路转接,此处我们是知道除了0、1、2这三个文件描述符就只有一个_listenSock了,后面文件描述符会随着不断地accept而越来越多,是一个动态增加的过程,而且这里的动态增长完全是通过listenSock来实现的。
前面讲TCP的时候,通信前要进行三次握手,而三次握手本质上也是在通信(握手报文的通信),获取新的连接,在IO角度来看,就是input事件,对于连接的input,所以listenSock读事件就绪,对应的就是能获取新连接了,对应到普通文件的读事件就绪就是能进行读取。
如果没有连接到来,accept就会阻塞,和前面讲的read阻塞是一样的,都是等这个listenSock文件描述符,所以这里就不能直接调用accept了,因为调了进程就会自己去等。
所以这里也要把listenSock当成一个普通的文件描述符加入到select中去,让select帮进程等,select只要告诉用户listenSock就绪了,就直接调用accept,这样accept就不会再阻塞了,所以这里要先调用select。
本篇所讲的select相对于epoll来说没有那么重要,所以只演示一下读文件描述符集,等后面讲epoll了再将三个文件描述符集都演示一下。
timeout 为 nullptr
调用select:
这里根据select的返回值来选择该干什么事情:
这样运行起来的话会先阻塞:
用telnet连接:
会死循环打印listenSock的读已经准备好了。
因为连接上了以后一直没有取走连接,底层中listenSock对应的资源一直是就绪的,就是连接已经建立完成了,accept一直没有取走底层对应连接的文件描述符,所以select要一直通知你赶紧调用select。
timeout 为 {0, 0}
先不调用accept,把timeout改成{0,0}看看:
刚运行起来就一直打印time out:
因为这里timeout设置成{0, 0}就是非阻塞等待,和前面的非阻塞的read一样,所以一般不这么用。
timeout 为 {5, 0}
我再来把timeout改成{5, 0}:
刚运行没问题:
但是5s后又开始疯狂打印了:
因为timeout参数是输入输出型的,第一次作为输出参数会被改成{0, 0},而我刚刚故意将tv的定义放在了while外面,所以就会导致后续的tv都变成{0, 0},这样就会和上面的情况一样,变成了非阻塞IO,所以要把tv定义放在while中或者在while中更新tv中的值:
这样就不会那么快:
调用accept
再来说回timeout为nullptr的情况:
因为接收连接后还会有后续动作,所以再给一个函数把后续动作放到一起更方便观察,这里我们是知道只有一个listenSock的,所以写的简单点,等后面有新场景了再做修改:
运行起来:
一切正常。
这里我故意把通信过程留下来了,请问通信的时候能直接recv/read吗?
很显然是不能的,我前面写的TCP服务器至少都是创建进程/线程去专门负责读取,更不用谈现在单进程的情况下想直接读了,我们这里想实现一个单线程既能实现监听又能实现接受连接的,但当前状态下单线程直接读,如果用户不发消息进程直接就阻塞了,没办法向后执行,也就无法处理新的连接,本质原因还是我们不清楚sock上面数据什么时候到来,但是如果把sock也能放到select中select就清楚什么时候到来。
所以得到新的连接后,此时我应该考虑的是将新的sock托管给select,让select帮我们进行监测sock上是否有新数据,有了新数据select就会通知我,此时再进行读取就不会再阻塞,但是如何把新的sock交给select呢?以现在的写法无法实现。
前面说了,写一半就写不下去了,下面就得讲讲select编写代码的一般流程了。
select编写代码的一般流程
再看看这个接口:
第一个参数nfds,随着我们获取的sock越来越多,需要添加到select中的sock也就会越来越多,那么就注定了每一次调用select时nfds都可能要改变,所以要对nfds动态计算。
readfds/writefds/exceptfds都是输入输出参数,输入和输出不一定会一样,比如说传入1111,输出0010,那再次输入的时候还要改成1111,所以我们每一次都要对rfds重新添加。
timeout,也是输入输出,如果设置了时间,每次都要重置。
对于1、2两点而言,主要原因是文件描述符可能每次都在变,想要完全掌握其变化就要自己将合法的文件描述符全部保存起来,用来支持更新最大fd和更新位图结构。
所以select服务器编写的时候:
需要一个第三方数组用来保存所有合法的fd,数组就是select能同时监听的fd个数(元素个数)。我这里等会就直接用原生数组来实现了,也可以用vector,会更方便一点,但至于为什么用原生数组等会写完了再说。
上面的流程大致如下:
while(1)
{
- 遍历数组,更新最大的fd,用于select中第一个参数
- 遍历数组,添加所有需要关心的fd到fd_set位图中,用于select第二个参数
- 调用select进行实践检测
- 遍历数组,找到就绪的事件,根据就绪的事件完成对应的动作。
}
重写
这里直接将数组开完整,select最大能监听的文件描述符的个数为1024个,也就是fd_set位图的位数大小,前面也讲过了。用这个数组来存放合法的sock(合法就是指能用的)。
构造函数里面初始化一下:
那么代码就要改改了:
每次都打印一下其中有效的文件描述符:
每次都要对数组进行操作,变化的就是红框中的:
EventHandler也要改:
想要将sock添加到select中,其实只要将sock放到数组中就行,EventHandler调用完毕后会循环回去,遍历后就会放到位图中。
将新的连接加入select中:
测试一下,刚运行:
连一个:
连两个:
很正常。
每次进行select的时候,若有文件描述符就绪,会有两种情况:
- 就绪的是listenSock
- 就绪的是sock
这两种文件描述符是不同的情况,处理方式也是不同的。listenSock是用来获取连接的,sock是用来通信时读取用户数据的。
那么EventHandler处理就绪的文件描述符时要先遍历一下_fdArray,找到合法的文件描述符并判断文件描述符是否在os输出的rfds中(用来判断有效的文件描述符是否就绪),若在,还要判断是listenSock还是普通通信的sock,如果是listenSock就要接收连接,如果是sock,就要进行读取。分两种方式,那么刚刚实现的EventHandler只是实现了接收连接,读取还没有实现,这两个方法完全可以再实现成两个函数,一个reader用来实现读取,一个accepter用来实现接收连接。
把这两个函数实现给出:
其实接收连接就是刚刚写的代码。
获取数据:
这样本次读取的时候就不会再阻塞。
然后EventHandler改成:
测试一下,刚运行(这里接收到连接后的listenSock is ready忘改了,你懂我就行):
连接一个:
连接两个:
连接三个:
第一个连接通信:
第二个连接通信:
第三个连接通信:
挨个退出:
成功。
其实上面的read是有bug的,因为传输层TCP是面向字节流的,不能保证每次读取到的是一个完整的报文,就像我前面的网络版本计算器一样,应用层需要自己手动定制协议,不软会出现粘包问题,这里就不改了,等后面讲epoll的博客再解决这个问题。
上面的select服务器是一个单进程单线程的服务器,但是依旧能并发的执行任务。
如果想要引入写呢?也就是writefds参数。
简单说一下思路,就是再定义一个_wrArray数组,用来保存写的文件描述符,后续的流程和_rdArray差不多。这里就不细说了,等后面讲epoll了再说。
完整代码
服务器头文件:
#include "Sock.hpp"
#include <assert.h>
#define NUM (sizeof(fd_set) * 8) // 数组元素个数
#define FD_NONE -1 // 数组初始化的值,表明没有这个fd
class SelectServer
{
public:
SelectServer(uint16_t port = 8080)
:_port(port)
{
// 创建套接字
_listenSock = Sock::Socket();
// bind绑定
Sock::Bind(_listenSock, _port);
// 设置监听状态
Sock::Listen(_listenSock);
// 对_rdArray数组初始化
for(int i = 0; i < NUM; ++i)
{
_rdArray[i] = FD_NONE; // 每一个都设置成FD_NONE,表明某一位没有文件描述符
}
// 规定第一个位为_listenSock,因为_listenSock一直存在
_rdArray[0] = _listenSock;
}
void Start()
{
while(1)
{
showFds(); // 每次打印一下数组中有效的fd
fd_set rfds; // 读文件描述符集
FD_ZERO(&rfds); // 初始化
// 找出最大的文件描述符
int maxfd = _listenSock;
for(int i = 0; i < NUM; ++i)
{
if(_rdArray[i] == FD_NONE) continue;
// 找出最大的文件描述符
if(maxfd < _rdArray[i]) maxfd = _rdArray[i];
// 有效的文件描述符设置到select中
FD_SET(_rdArray[i], &rfds);
}
int n = select(maxfd + 1, &rfds, nullptr, nullptr, nullptr);
// select第一个参数为最大文件描述符 + 1,这里最大的文件描述符就是maxfd
// 中间只关心读文件描述符集,所以只搞了一个,后面两个都是空
// 最后一个是timeout,先演示一下nullptr为空,阻塞等待
// timeval tv;
// tv.tv_sec = 5;
// tv.tv_usec = 0;
// int n = select(_listenSock + 1, &rfds, nullptr, nullptr, &tv);
switch(n)
{
case 0:
LogMessage(DEBUG, _F, _L, "time out");
break;
case -1:
LogMessage(ERROR, _F, _L, "select err, errno::%d, strerror::", errno, strerror(errno));
break;
default:
LogMessage(NORMAL, _F, _L, "fd is ready");
EventHandler(rfds);
break;
}
}
}
void EventHandler(fd_set& rfds)
{
for(int i = 0; i < NUM; ++i)
{
// 是否有效
if(_rdArray[i] == FD_NONE) continue;
// 是否就绪
if(FD_ISSET(_rdArray[i], &rfds))
{
if(i == 0)// 是listenSock
{
Accepter();
}
else // 是通信的sock
{
Reader(i);
}
}
}
// if(FD_ISSET(_listenSock, &rfds))
// {
// // 客户端IP + 端口
// std::string clientIP;
// uint16_t clientPort;
// int sock = Sock::Accept(_listenSock, clientIP, clientPort);
// assert(sock >= 0);
// LogMessage(NORMAL, _F, _L, "get link -->client[%s:%d]", clientIP.c_str(), clientPort);
// // 通信过程...
// int pos = 1;
// for(; pos < NUM; ++pos)
// {// 找FD_NONE
// if(_rdArray[pos] == FD_NONE) break;
// }
// if(pos == NUM)
// {// 没找到
// std::cout << "文件描述符集已满, 无法继续接收连接" << std::endl;
// close(sock);
// return;
// }
// else
// {// 找到了
// std::cout << "new fd::" << sock << std::endl;
// _rdArray[pos] = sock;
// }
// }
}
void Accepter()
{
// 客户端IP + 端口
std::string clientIP;
uint16_t clientPort;
int sock = Sock::Accept(_listenSock, clientIP, clientPort);
assert(sock >= 0);
LogMessage(NORMAL, _F, _L, "get link -->client[%s:%d]", clientIP.c_str(), clientPort);
// 通信过程...
int pos = 1;
for(; pos < NUM; ++pos)
{// 找FD_NONE
if(_rdArray[pos] == FD_NONE) break;
}
if(pos == NUM)
{// 没找到
std::cout << "文件描述符集已满, 无法继续接收连接" << std::endl;
close(sock);
return;
}
else
{// 找到了
std::cout << "new fd::" << sock << std::endl;
_rdArray[pos] = sock;
}
}
void Reader(int pos)
{
char buff[128] = {0};
ssize_t res = read(_rdArray[pos], buff, sizeof(buff) - 1);
if(res > 0)
{// 读取到数据
buff[res - 1] = 0;
printf("get client[%d] message # %s\n", _rdArray[pos], buff);
}
else if(res == 0)
{// 对端关闭连接
printf("client[%d] closed, me too\n", _rdArray[pos]);
close(_rdArray[pos]);
// 记得要把数组中对应位置置为FD_NONE
_rdArray[pos] = FD_NONE;
}
else
{// read出错
printf("read err, close client[%d]\n", _rdArray[pos]);
std::cout << "read err ::" << errno << strerror(errno) << std::endl;
close(_rdArray[pos]);
// 记得要把数组中对应位置置为FD_NONE
_rdArray[pos] = FD_NONE;
}
}
void showFds()
{
std::cout << "fds ::";
for(auto e : _rdArray)
{
if(e == FD_NONE) continue;
std::cout << e << ' ';
}
std::cout << std::endl;
}
~SelectServer()
{
if(_listenSock >= 0)
{
close(_listenSock);
}
}
private:
uint16_t _port;
int _listenSock;
int _rdArray[NUM];
};
主函数:
#include "SelectServer.hpp"
#include <memory>
int main()
{
std::unique_ptr<SelectServer> pss(new SelectServer);
pss->Start();
return 0;
}
select优缺点
优点:
- 效率高,相比于前面多线程多进程的服务器,select服务器比多进/线程服务器效率会更高。select()函数可以同时等待多个文件描述符,而不需要建立多个线程、进程就可以实现一对多的通信。但是select放在整个多路转接中的效率还是一般的,好的都在后面讲。
- 应用场景:有大量的连接,但是只有少量是活跃的。前面的多进程/多线程服务器,有一个连接就要维护一个进程/线程的空间,对于资源的消耗会很大。但这里select不需要维护这些空间,只有一个线程。
其实任何一个多路转接都具备上述两个优点。
缺点:
- 为了维护第三方数组,select服务器会充满大量的遍历,os底层帮我们关心fd的时候也要遍历。
- 每一次都要对select参数进行重新设定
- 能够同时管理的fd的个数是有上限的,一千多个,有点少,中小型应用还好,用户量一大就扛不住。
- 因为几乎每一个参数都是输入输出型,select一定会频繁的进行用户到内核,内核到用户的参数数据拷贝。
- 编写代码比较复杂,主要还是前面4个缺点导致的。
poll可以解决这里的部分缺点。下面就来说说poll。
poll
poll也是多路转接的方案,也是只负责IO中的等。
poll将输入输出参数做了分离,不用再对参数重新设定了。而且解决了同时管理fd个数上限的问题。
三个参数。fds是看成数组,nfds就是数组中元素的个数。等会细说pollfd结构体。
timeout是一个毫秒级别的时间单位,比如说你传一个1000,就是未就绪1s后超时,如果传0就是非阻塞,如果传-1就是阻塞。
poll返回值大于零,是几就是几个文件描述符就绪了。
等于零,超时。
小于零,poll失败,代码写错了,比如根本不存在5号文件描述符但是你把文件描述符添加到了第一个参数数组中。
poll也是负责两个大问题:
- 用户告诉内核,你要帮我关心哪些fd的哪些事件
- 内核告诉用户,哪些事件已经就绪了。
第一个参数fds就能解决这两个问题。
这个数组中元素类型为pollfd:
三个成员:
fd就是文件描述符,不管是用户到内核还是内核到用户,都不会修改fd。
events就是你要让os关心的fd的什么事件,是一个输入型参数。
revents算是一个输出型参数,表明你要让os关心的fd中的事件是否就绪。
这样每次调用poll的时候就不会像select那样重新初始化了。
select中有读、写、异常这样的事件,events如何表示这类事件呢?
想一想文件操作open,当我们想要打开文件的标记位,就是用或运算,比如O_CREAT,O_WRONLY,O_RDONLY这样的标记位。同理,poll用的也是这样的宏来表示某种特定事件:
我已经把常用的标出来了。in、out就是读写,err就是错误。剩下的都是一些属于异常范畴的,因为event类型为short,只有16个位,所以最多只能有16中标记。上面这些每一个都是宏,用或即可添加选项。
看看POLLPRI,高优先级数据可读,前面我讲TCP报头的时候其中有一个urg标志位,还有一个紧急指针,在这里就可配合POLLPRI来实现。
来一个示例:
#include <poll.h>
#include <unistd.h>
#include <stdio.h>
int main()
{
// 这里就监测一下标准输入,就不搞那么多文件描述符了
struct pollfd poll_fd;
poll_fd.fd = 0;
poll_fd.events = POLLIN; // 标准输入的读事件
for (;;)
{
// 每隔一秒poll一次
int ret = poll(&poll_fd, 1, 1000);
if (ret < 0)
{ // poll错误
perror("poll");
continue;
}
if (ret == 0)
{ // 超时
printf("poll timeout\n");
continue;
}
// 事件准备就绪
if (poll_fd.revents == POLLIN)
{// 判断一下是不是读事件就绪了
char buf[1024] = {0};
read(0, buf, sizeof(buf) - 1);
printf("stdin:%s", buf);
}
}
}
运行:
下面来写写poll服务器,其实和select还是有点像的,写起来比select简单一点,这里用一下select的大致框架:
其中一些函数参数如果用到了再添加。
首先poll要有一个数组,元素类型为pollfd:
构造函数初始化:
打印有效文件描述符:
启动:
EventHandler:
接收连接:
读取数据:
测试,连一个:
连两个:
连三个:
发消息:
挨个退:
正常。
完整代码:
服务器封装的头文件:
#include "Sock.hpp"
#include <assert.h>
#include <poll.h>
#define FD_NONE -1 // 每个fd的初始化的值
#define NFDS 100 // 数组元素个数
class PollServer
{
public:
PollServer(uint16_t port = 8080)
: _port(port)
, _nfds(NFDS)
{
// 创建套接字
_listenSock = Sock::Socket();
// bind绑定
Sock::Bind(_listenSock, _port);
// 设置监听状态
Sock::Listen(_listenSock);
// 开辟空间
_fds = new pollfd[_nfds];
for(int i = 0; i < _nfds; ++i)
{ // 初始化
_fds[i].fd = FD_NONE;
_fds[i].events = _fds[i].revents = 0;
}
// 第零个位置给成listenSock
_fds[0].fd = _listenSock;
_fds[0].events = POLLIN; // 关系listenSock的读
}
void showFds()
{
std::cout << "fds:: ";
for(int i = 0; i < _nfds; ++i)
{
if(_fds[i].fd == FD_NONE) continue;
std::cout << _fds[i].fd << ' ';
}
std::cout << std::endl;
}
void Start()
{
while(1)
{
showFds();
// 1s间隔
int res = poll(_fds, _nfds, -1);
if(res > 0)
{ // 有文件描述符就绪
std::cout << "some fds' ready" << std::endl;
EventHandler();
}
else if(res == 0)
{ // 超时
std::cout << "time out" << std::endl;
}
else
{ // poll出错
printf("poll err, errno[%d], strerror::%s", errno, strerror(errno));
}
}
}
void EventHandler()
{
for(int i = 0; i < _nfds; ++i)
{
// 第i位不是有效文件描述符
if(_fds[i].fd == FD_NONE) continue;
// 读事件时候就绪
if(_fds[i].revents & POLLIN)
{
if(i == 0)
Accepter();
else
Reader(i);
}
}
}
// 接收连接
void Accepter()
{
// 获取连接
std::string clientIP;
uint16_t clientPort;
int sock = Sock::Accept(_listenSock, clientIP, clientPort);
// 找空位置放sock
int pos = 1;
for(; pos < _nfds; ++pos)
{
if(_fds[pos].fd == FD_NONE) break;
}
if(pos == _nfds)
{ // 没找到,不过这里也可以选择对_fds进行扩容,但是我懒得搞了,你要是有兴趣可以自己搞一下
std::cout << "_nfds is full" << std::endl;
close(sock);
}
else
{ // 找到了
std::cout << "get a new link ::" << sock << std::endl;
_fds[pos].fd = sock;
_fds[pos].events = POLLIN;
}
}
// 读取数据
void Reader(int pos)
{
char buff[128];
int res = read(_fds[pos].fd, buff, sizeof(buff) - 1);
if(res > 0)
{ // 读取到数据
buff[res] = 0;
std::cout << "client #" << buff << std::endl;
}
else if(res == 0)
{ // 对端关闭连接
std::cout << "clinet closed" << std::endl;
// 记得后续工作
close(_fds[pos].fd);
_fds[pos].fd = FD_NONE;
_fds[pos].events = _fds[pos].events = 0;
}
else
{ // 读取出错
printf("read err, errno[%d], strerror::%s", errno, strerror(errno));
// 记得后续工作
close(_fds[pos].fd);
_fds[pos].fd = FD_NONE;
_fds[pos].events = _fds[pos].events = 0;
}
}
~PollServer()
{
if(_listenSock >= 0) close(_listenSock);
if(_fds != nullptr) delete[] _fds;
}
private:
uint16_t _port;
int _listenSock;
pollfd *_fds;
int _nfds;
};
主函数:
#include "PollServer.hpp"
#include <memory>
int main()
{
std::unique_ptr<PollServer> pps(new PollServer);
pps->Start();
return 0;
}
poll的优缺点
优点:
-
效率高(更select一样)
-
适用场景:有大量的连接但是只有少量连接是活跃的,节省资源
-
输入输出参数是分离的,不需要进行大量的重置。
-
poll参数nfds可以自行设定,没有上限(除非内存不够)。
缺点:
-
poll依旧需要不少的遍历,在用户层监测事件就绪与内核监测fd就绪,都是一样的,当只有几个就绪时就要将整个数组遍历一遍,效率比较低(连接越多越低)
-
poll需要用户和内核进行拷贝,更多的是需要内核到用户的拷贝,少不了的。
-
poll代码比select容易,但还是有点复杂
最需要关心的缺点就是第一点,用户还是要维护数组。
为了解决上述问题,epoll出现了,强化版本的poll,要比poll强得多,关于epoll下一篇再详细说。
本篇就先讲到这里。下一篇详细讲解多路转接中最重要的epoll。
到此结束。。。