套接字

    套接字编程接口,是在 TCP/IP 协议族中,应用层进入传输层的接口。用套接字编写使用 TCP或UDP 的网络应用程序。应用层是用户进程,下面是系统内核的一部分功能。原始套接字,raw socket,应用不使用传输层协议,直接用IP协议,例如:OSPF

2.1 套接字与I/O模型

套接字(socket): 一个标识端点的 IP、端口 组合。{ip : port}

套接字对(socket pair): 通过两端端点的四元组,唯一地标识一个连接。{本地ip : 本地port,对端ip : 对端port}

监听套接字(listening socket),{* : *},等待连接请求,被动打开。服务器的监听套接字对用{x.x.x.x : port_num,* : *}表示,匹配所有源/对端ip和源/对端端口

已连接套接字(connected socket),accept 返回的,三次握手完毕的套接字,已连接套接字对用{x.x.x.x : port_num,ip : port}表示,已连接套接字使用与监听套接字相同的本地IP和本地端口(单宿主服务器)。

    TCP与套接字

2.1 套接字与I/O模型

    以单进程阻塞I/O为例,解释套接字中函数对应的TCP操作

2.1 套接字与I/O模型

socket()

    创建套接字,指定要使用的地址簇(ip)和套接字类型/传输层协议(tcp),返回一个套接字描述符,简称sockfd,用于在函数调用(connect、read 等)中标识这个套接字。

bind()

    将一个 本地IP:本地端口赋值给一个套接字,限定该套接字只接收目的地为指定的本地IP、端口的客户连接。进程指定。

linsen()

    仅由 TCP server 调用。当 socket 函数创建了套接字,会假设为主动套接字,也就是将调用 connect 主动发起连接的套接字。linsen 将一个未连接的套接字转换成被动套接字,通知内核应该接受指向该套接字的连接请求(被动打开)。此时套接字的状态从 CLOSED 转到 LISTEN。

内核为每个监听套接字维护两个队列:
    未完成连接队列,收到了客户的 SYN,正在等待 TCP 三次握手完成。此时套接字的状态为 SYN_RCVD。
    已完成连接队列,已经完成 TCP 三次握手的客户,这些套接字处于 ESTABLISHED 状态。

    当收到客户的SYN,TCP在未完成连接队列中创建一项新条目,同时继承监听套接字的参数,然后返回SYS、ACK,这一项一直保留在未完成连接队列中,直到收到 ACK,或者该项超时。如果三次握手正常完成,该项从未完成连接队列移到已完成连接队列的队尾。

accept()

    仅由 TCP server 调用,从已完成连接的队头获得一个已完成连接,如果已完成连接的队列为空,进程被挂起进入睡眠状态(假定套接字为默认的阻塞方式),直到队列中有条目。接收监听套接字描述符,返回由内核自动为获得的已完成连接生成的已连接套接字描述符,以及对应的已连接套接字。

connect()

    仅由 TCP client 调用,接收 socket 返回的套接字描述符,主动向 TCP server 发送建立连接的请求(主动打开),触发三次握手。调用 connect 之前不一定要调用 bind 绑定本地 ip 和本地端口,这样,内核会通过数据出口确定源 ip,并选定一个临时端口作为源端口。

fork()、exec()
调用一次,返回两次
    在父进程中返回一次,返回的是新的子进程ID号(pid),用于记录并跟踪所有子进程

    在子进程中返回一次,始终为0,因为每个子进程都只有一个父进程,而且可以通过getppid获取父ID(ppid)

    父进程调用fork前打开的所有描述符,在fork返回后,与子进程共享(复制程序代码与描述符)每个文件/套接字都有一个引用计数器,在文件表项中维护,表示当前打开的 引用了该文件/套接字的描述符 个数。close 只是将计数值减一,真正清理套接字、释放资源,需要计数值为0。

fork 的两种典型用法:

   1、 一个进程想要执行另一个程序

fork,新创一个进程,在内存中运行相同的程序代码

exec,换成另一个程序代码

2.1 套接字与I/O模型

   2、 一个进程创建自身的副本,各自同时处理各自的操作,比如网络服务

close()

    当套接字描述符的引用为0,发送 FIN 关闭套接字,终止 TCP 连接。默认行为是将该套接字标记为已关闭,然后立即返回。这个套接字的描述符不能再被 read 或 write 使用。

shutdown()
    不管引用计数器,直接发送 FIN,可以半关闭连接。

I/O模型

    同步/异步:关注的是消息通信机制

同步:synchronous,调用者等待被调用者返回消息,才能继续执行

异步:asynchronous,被调用者通过状态、通知或回调机制主动通知调用者被调用者的运行状态

    阻塞/非阻塞:关注调用者在等待结果返回之前所处的状态

阻塞:blocking,指IO操作需要彻底完成后才返回到用户空间,调用结果返回之前,调用者被挂起

非阻塞:nonblocking,指IO操作被调用后立即返回给用户一个状态值,无需等到IO操作彻底完成,最终的调用结果返回之前,调用者不会被挂起

    Linux下的五种I/O模型

阻塞型、非阻塞型、复用型、信号驱动型、异步

一个输入操作的两个阶段

    1、等待数据准备好。对于套接字,是等待从网络收到数据,并且在数据到达后,复制数据到内核缓冲区

    2、 从内核缓冲区复制数据到进程缓冲区,以便进程处理

2.1 套接字与I/O模型

    同步阻塞I/O模型

    同步阻塞I/O模型是最简单的I/O模型,用户线程在内核进行I/O操作时被阻塞
    用户线程通过系统调用read发起I/O读操作,由用户空间转到内核空间。内核等到数据包到达后,然后将接收的数据拷贝到用户空间,完成read操作。用户需要等待read将数据读取到buffer后,才继续处理接收的数据。整个I/O请求的过程中,用户线程是被阻塞的,这导致用户在发起I/O请求时,不能做任何事情,对CPU的资源利用率不够。

2.1 套接字与I/O模型

    同步非阻塞I/O模型

    用户线程发起I/O请求时立即返回。但并未读取到任何数据,用户线程需要不断地发起I/O请求,直到数据到达后,才真正读取到数据,继续执行。即“轮询”机制

    整个I/O请求的过程中,虽然用户线程每次发起I/O请求后可以立即返回,但是为了等到数据,仍需要不断地轮询、重复请求,消耗了大量的CPU的资源,是比较浪费CPU的方式,一般很少直接使用这种模型,而是在其他IO模型中使用非阻塞IO这一特性。

2.1 套接字与I/O模型

    I/O多路复用模型

    多个连接共用一个等待机制,本模型会阻塞进程,但是进程是阻塞在select或者poll这两个系统调用上,而不是阻塞在真正的IO操作上。用户首先将需要进行IO操作添加到select中,继续执行做其他的工作(异步),同时等待select系统调用返回。当数据到达时,I/O被**,select函数返回。用户线程正式发起read请求,读取数据并继续执行。
    从流程上来看,使用select函数进行I/O请求和同步阻塞模型没有太大的区别,甚至还多了添加监视I/O,以及调用select函数的额外操作,效率更差。并且阻塞了两次,但是第一次阻塞在select上时,select可以监控多个I/O上是否已有I/O操作准备就绪,即可达到在同一个线程内同时处理多个I/O请求的目的。而不像阻塞I/O模型,一次只能监控一个I/O。

    虽然上述方式允许单线程内处理多个I/O请求,但是每个I/O请求的过程还是阻塞的(在select函数上阻塞),平均时间甚至比同步阻塞I/O模型还要长。如果用户线程只是注册自己需要的I/O请求,然后去做自己的事情,等到数据到来时再进行处理,则可以提高CPU的利用率。I/O多路复用是最常使用的I/O模型,但是其异步程度还不够“彻底”,因为它使用了会阻塞线程的select系统调用。因此I/O多路复用只能称为异步阻塞I/O模型,而非真正的异步I/O。

2.1 套接字与I/O模型

I/O多路复用适用如下场合:
    当客户端处理多个描述符时(一般是交互式输入和网络套接口),必须使用I/O复用
    当一个客户端同时处理多个套接字时,此情况可能的但很少出现
    当一个TCP服务器既要处理监听套接口,又要处理已连接套接口,一般也要用到I/O复用
    当一个服务器即要处理TCP,又要处理UDP,一般要使用I/O复用
    当一个服务器要处理多个服务或多个协议,一般要使用I/O复用

    信号驱动I/O模型

    用户进程可以通过sigaction系统调用注册一个信号处理程序,然后主程序可以继续向下执行,当有I/O操作准备就绪时,由内核通知触发一个SIGIO信号处理程序执行,然后将用户进程所需要的数据从内核空间拷贝到用户空间。
    此模型的优势在于等待数据报到达期间进程不被阻塞。用户主程序可以继续执行,只要等待来自信号处理函数的通知。

2.1 套接字与I/O模型

    异步I/O模型

    异步IO与信号驱动IO最主要的区别是信号驱动IO是由内核通知何时可以进行IO操作,而异步IO则是由内核告诉我们IO操作何时完成了。具体来说就是,信号驱动IO当内核通知触发信号处理程序时,信号处理程序还需要阻塞在从内核空间缓冲区拷贝数据到用户空间缓冲区这个阶段,而异步IO直接是在第二个阶段完成后内核直接通知可以进行后续操作了。
    相比于IO多路复用模型,异步IO并不十分常用,不少高性能并发服务程序使用IO多路复用模型+多线程任务处理的架构基本可以满足需求。况且目前操作系统对异步IO的支持并非特别完善,更多的是采用IO多路复用模型模拟异步IO的方式(IO事件触发时不直接通知用户线程,而是将数据读写完毕后放到用户指定的缓冲区中)

2.1 套接字与I/O模型


I/O模型主要的实现方式

Select:Linux实现对应,I/O复用模型,BSD4.2最早实现
Poll:Linux实现,对应I/O复用模型,System V unix最早实现
Epoll:Linux实现,对应I/O复用模型,具有信号驱动I/O模型的某些特性
Kqueue:FreeBSD实现,对应I/O复用模型,具有信号驱动I/O模型某些特性
/dev/poll:SUN的Solaris实现,对应I/O复用模型,具有信号驱动I/O模型的某些特性
Iocp Windows实现,对应异步I/O模型

Select、Poll

    select函数会等待,直到描述符句柄中有可用资源(可读、可写、异常)时返回,返回值是可用资源(可读/可写/异常等)描述符的个数(>0),0代表超时,-1代表错误。具体到内核大致是:当应用程序调用select() 函数, 内核就会相应调用 poll_wait(), 把当前进程添加到相应设备的等待队列上,然后将该应用程序进程设置为睡眠状态。直到该设备上的数据可以获取,然后调用wake_up()唤醒该应用程序进程。select每次轮训都会遍历所有描述符句柄

(1)使用copy_from_user从用户空间拷贝fd_set到内核空间
(2)注册回调函数__pollwait
(3)遍历所有fd,调用其对应的poll方法(对于socket,这个poll方法是sock_poll,sock_poll根据情况会调用到tcp_poll,udp_poll或者datagram_poll)
(4)以tcp_poll为例,其核心实现就是__pollwait,也就是上面注册的回调函数。
(5)__pollwait的主要工作就是把current(当前进程)挂到设备的等待队列中,不同的设备有不同的等待队列,对于tcp_poll来说,其等待队列是sk->sk_sleep(注意把进程挂到等待队列中并不代表进程已经睡眠了)。在设备收到一条消息(网络设备)或填写完文件数据(磁盘设备)后,会唤醒设备等待队列上睡眠的进程,这时current便被唤醒了。
(6)poll方法返回时会返回一个描述读写操作是否就绪的mask掩码,根据这个mask掩码给fd_set赋值。
(7)如果遍历完所有的fd,还没有返回一个可读写的mask掩码,则会调用schedule_timeout是调用select的进程(也就是current)进入睡眠。当设备驱动发生自身资源可读写后,会唤醒其等待队列上睡眠的进程。如果超过一定的超时时间(schedule_timeout指定),还是没人唤醒,则调用select的进程会重新被唤醒获得CPU,进而重新遍历fd,判断有没有就绪的fd。
(8)把fd_set从内核空间拷贝到用户空间。

    select函数执行结果:执行成功则返回文件描述词状态已改变的个数,如果返回0代表在描述词状态改变前已超过timeout时间,没有返回;当有错误发生时则返回-1,错误原因存于errno,此时参数readfds,writefds,exceptfds和timeout的值变成不可预测。错误值可能为:

EBADF 文件描述词为无效的或该文件已关闭
EINTR 此调用被信号所中断
EINVAL 参数n 为负值。

ENOMEM 核心内存不足

Select、Poll 的缺点

    单个进程可监视的fd数量被限制,即能监听端口的数量有限
    对socket是线性扫描,即采用轮询的方法,效率较低
    select 采取了内存拷贝方法来实现内核将 FD 消息通知给用户空间,这样一个用来存放大量fd的数据结构,这样会使得用户空间和内核空间在传递该结构时复制开销大

Epoll

    select和poll都只提供了一个函数——select或者poll函数。而epoll提供了三个函数,epoll_create,epoll_ctl和epoll_wait,epoll_create是创建一个epoll句柄;epoll_ctl是注册要监听的事件类型;epoll_wait则是等待事件的产生。

    对于第一个缺点,epoll的解决方案在epoll_ctl函数中。每次注册新的事件到epoll句柄中时(在epoll_ctl中指定EPOLL_CTL_ADD),会把所有的fd拷贝进内核,而不是在epoll_wait的时候重复拷贝。epoll保证了每个fd在整个过程中只会拷贝一次。

  对于第二个缺点,epoll的解决方案不像select或poll一样每次都把current轮流加入fd对应的设备等待队列中,而只在epoll_ctl时把current挂一遍(这一遍必不可少)并为每个fd指定一个回调函数,当设备就绪,唤醒等待队列上的等待者时,就会调用这个回调函数,而这个回调函数会把就绪的fd加入一个就绪链表)。epoll_wait的工作实际上就是在这个就绪链表中查看有没有就绪的fd(利用schedule_timeout()实现睡一会,判断一会的效果,和select实现中的第7步是类似的)。

  对于第三个缺点,epoll没有这个限制,它所支持的FD上限是最大可以打开文件的数目,这个数字一般远大于2048,举个例子,在1GB内存的机器上大约是10万左右,具体数目可以cat /proc/sys/fs/file-max察看,一般来说这个数目和系统内存关系很大。

  select poll epoll
操作方式 遍历 遍历 回调
底层实现 数组 链表 哈希表
I/O效率 时间复杂度O(n) 时间复杂度O(n) 时间复杂度O(1)
最大连接数 1024或2048 无上限 无上限
fd拷贝 每次调用select,都需要把fd集合从用户态拷贝到内核态 每次调用poll,都需要把fd集合从用户态拷贝到内核态 调用epoll_ctl是拷贝进内核并保存,之后每次epoll_wait不拷贝


相关文章: