linux-interface-socket
The linux programming interface
Socket
socket是一种IPC方法, 它允许位于同意主机或者网络连接的不同主机 上的应用程序之间交换数据(第一个被广泛接收的socket API 实现于 1983年,现在这组API 已经被移植到了大部分的计算机系统上)
- socket(domain, type, protocol): 系统调用
-
domain: 1)识别 socket 地址的格式 2) 确定范围: 在同一个主机上的不同应用程序还是 在一个网络上的不同主机
DOMAIN 执行的通讯 应用程序间的通讯 地址格式 地址结构 AF_UNIX 内核中 同一主机 路径名 sockaddr_un AF_INET 通过IPv4 IPv4 连接起来的网络 32为IPoe 地址+ 16位端口号 sockaddr_in AF_INET6 通过IPv6 IPv6 连接起来的网络 128为IP地址+ 16位端口号 sockaddr_in6 -
type: sock_stream, sock_dgram
属性 流(SOCK_STREAM) 数据包 (SOCK_DGRAM) 可靠的传输? 是 否 边界消息保留? 否 是 面向连接? 是 否 - 流: 提供了一个可靠的双向的字节流的通讯通道。 (因为需要一对 相互连接的socket,因为被称为面向连接的socket)其中:
- 可靠的: 表示可以保证发送者传输的数据会完整的传递到接收者应用程序 (假设接收者发送者应用程序不会崩溃)
- 双向的: 数据可以在socket 之间的任意方向上传输
- 字节流: 表示与管道一样不存在 消息边界的概念
- 数据报 socket: 允许数据以 数据报的消息形式进行交换, 在数据报socket中, 消息边界得到了保留,但是数据传输是不可靠的,消息的到达顺序 可能是无序的、重复的、或者根本无法到达的。数据包socket是一个更一般的无连接socket概念的一个示例, 与流socket连接,一个数据报 socket 在使用时候,无需与另一个socket 连接,现在internet domain 中, 数据包socket使用了UDP(用户数据报协议), 而流socket 则使用了TCP(传输控制协议)(是否意味着更多的协议的存在?)
-
- socket 相关的系统调用:
- socket(int domain, int type, int protocol): 其中domain, type 在上面有所描述。protocol 总是为0, 在一些socket类型中会使用非0数值,socket成功后会返回一个socket的文件描述符
- bind(int sockfd, struct sockaddr * addr, socklen_t addrlen): sockfd 为 socket调用返回的文件描述符,addr 为socket绑定到的地址结构指针,结构详细取决于 socket domain, addrlen 为结构地址大小。一般来讲服务器会将socket 绑定到一个 约定的地址上。
- listen(int sockfd, int backlog): 系统调用将会sockfd设定为 『被动』, 接收主动连接的请求。 backlog 用于 设定 服务器端 保持等待连接的数量。(在backlog之内的连接会立即成功,等待accept, 更多的连接会阻塞一直到有等待中的连接被accept并冲等待连接中删除掉)(backlog的限制在sys/socket.h 中的 somaxconn 常量设定, linux中 这个常量设定为128,从内核 2.4.25起 linux允许在运行时通过 特有的/proc/sys/net/core/somaxconn 文件来调整这个限制)
- accept(int sockfd, struct sockaddr * addr, socklen_t * addrlen): 系统调用在 sockfd 的文件描述符 引用的监听流socket上接收一个接入连接。如果在调用accpet时不存在 未决 的连接,那么调用就会阻塞直到 有连接请求为止。参数 addr, addrlen, 会返回连接socket 的地址信息。理解accept 的关键点在于:
- accept 会创建一个新的scoket, 这个socket与执行connect的客户端scoket进行连接。
- accpet调用返回的结果是 已连接的 socket文件描述符,监听 socketfd 会保持打开状态。并可以接收后续连接。
- 典型的 服务器应用 会创建一个 监听socketfd, 将其绑定到一个约定的地址上。然后 accept 该socketfd 上 的连接 来处理所有的客户端请求。
- connect(int sockfd, struct sockaddr * addr, scoklen_t addrlen): 系统调用将sockfd 主动连接到 地址addr 指定的监听socket上。如果连接失败,标准的可以移植的方法为: 关闭socket,创建一个新的socket,并重新连接
- 流 socket 提供了一个在两个端点之间 一个双向通信的通道,流socket IO 上的操作与 管道 IO的操作类似
- 可以使用 read, write,因为socket是双向的,所以两端都可以使用
- socket可以使用close来关闭,之后对应的另一端的socket 在读取数据时候会收到文件结束的标志,如果继续进行写入 会收到一个SIGPIPE的信号,并且系统调用会返回一个EPIPE的错误。
- 数据报 socket(SOCK_DGRAM):
- socket 系统调用创建一个邮箱,
- bind 到一个约定的地址上, 来允许 一个应用程序发送数据报 到这里,一般来讲, 一个服务器会将其socket 绑定到一个地址上,客户端会向该地址发送一个数据报 来发起通讯 (在一个domain 特别是UNIX domain 中,客服端想接收到服务器发送来的数据报的话,也需要bind到一个地址上)
- sendto(int sockfd, void * buffer, size_t length, int flags, sockaddr * dest_addr, socklen_t addrlen): 用来发送一个数据报, flags 用来控制一些socket的特性,dest_addr置顶了目标接收者的socket地址,
- recvfrom(int sockfd, void * buffer, size_t length, int flags, sockaddr * src_addr, socklen_t addrlen): 用来接收数据报,在没有数据报时候会阻塞应用。由于recvfrom允许获取发送者的地址,因为可以发送一个响应(这在 发送者的socket没有绑定到一个地址上是有用的,正如bind中的描述所说,unix domain中也需要 客服端 来bind一个地址,才能接收到服务器的响应)其中 src_addr 用来获取发送数据报的远程socket地址,如果并不关心发送者的地址,可以传递NULL,length 数值 用来限制recvfrom获取的数据大小,如果超过length,则会进行截断。(使用recvmsg 则可以找出被截断的数据报)
- 数据报通讯无法保证 数据报 接收的顺序,甚至无法保证数据是到达的 或者是 多次到达
- connect: 尽管数据报socket是无连接的,但是依然可以使用connect调用
- 发送者 socket connect之后,数据报的发送可以使用write来完成,而无需使用sendto,并每次传递addr地址。
- 接收者 socket connect之后,只能接收由对等的socket 发送的数据报
- 数据报socket connect的明显优势在于 可以使用更简单的IO 系统调用,在一些TCP/IP实践中,将一个数据报的socket连接到一个对等socket(connect)能够带来性能上的提升
Socket: Unix domain
-
Unix domain socket address:
unix domain socket的地址以路径名来表示,其中sun_path 的大小,早期为 108, 104,现在的一般为 92,可移植的需要小一些,应该使用strncpy 以避免缓冲区溢出问题,使用 路径名 初始化 sun_path 来初始化 socket addressstruct sockaddr_un { sa_family_t sun_family char sun_path[108]; }
- 当绑定 UNIX domain socket 时, bind会在 文件系统中创建一个条目, 文件的所有权会根据文件的创建规则来确定,并标记为一个socket, ls -l 第一列 为s, stat()返回的结构中st_mode字段中的文件类型部分为 S_IFSOCK,
- 无法将一个socket绑定到 现有的路径名上。
- 通常将一个socket绑定到绝对路径上
- 一个socket只能绑定到一个路径名上,相应的一个路径名只能被一个socket绑定
- 无法使用open打开一个socket
- 不在需要socket时,使用unlink 来删除其路径
- 示例中通常将socket绑定到/tmp目录下,这并不是一个好的设计,在现实中不要这么做,因为/tmp 此类公共可写的目录中创建文件会导致安全问题,所以应该将socket绑定到一个有安全措施的绝对路径上
- socketpair(int domain, int type, int protocol, int sockfd[2]): 该系统调用 用于创建一对 互相连接的socket,
- 只能用在UNIX domain中(也就是说 domain 必须指定为 AF_UNIX) type 可以为sock_dgram, sock_stream, protocol必须为0,
- sockfd 数组返回了 引用这两个相互连接的socket文件描述符。type 为sock_stream 相当于创建了一个双向管道,一般来讲 socket对的使用方式与管道的使用方式类似,在调用完socketpair()之后,可以fork出一个子进程,然后子父进程可以通过这一对socket来进行IPC了。
- 与 手动创建一对相互连接的socket的做法的优势: socketpair 创建的socket不会绑定到任意的地址上(即其他方式的socket创建都需要bind 到一个地址上)这样就能避免安全问题,因为这一对socket对其他进程是不可见的
- linux 抽象socket 命名空间
所谓的抽象命名空间 是linux特有的特性。他允许将一个UNIX domain socket绑定到一个名字上但不会在文件系统上创建该名字 优势有:- 无需担心与文件系统中的既有名字冲突
- 没有必要在使用完一个socket之后,删除socket路径名,当socket被关闭之后会自动删除这个抽象名
- 无需为socket创建一个文件系统路径名了
- 创建一个抽象的绑定,只需要将sun_path字段的第一个字节指定为null,用于区分抽象socket 与传统的UNIX domain socket
TCP/IP 网络基础
一个联网协议是定了如何在一个网络上传输信息的一组规则,网络协议通常会被组织成一系列的层,其中每一层都构建于下一层之上并提供特性以供上层使用。TCP/IP 协议套件 是一个封层联网协议。包括因特网协议(IP)和魏雨琦上层的各个协议层。 TCP 为传输层控制协议
- 封装协议的特点有:
- 透明: 每一个协议层都对上层 隐藏下层的操作和复杂性。 入一个使用TCP的应用程序只需要使用标准的socket API并清楚自己在使用 一项可靠的字节流传输服务,而无需理解TCP操作的细节。(严格来说 这个标准并不总是正确,应用程序偶尔也需要弄清楚 底层传输协议的操作细节)
- 封装: 是分层连网协议中的一个重要的原则。关键概念: 底层会将从高层向底层传递的信息 当成不透明的数据进行处理。并不会尝试对高哦曾发送过来的信息进行解释。只会 将这些信息 添加自身这一层所使用的头信息,并传递到下一层。当数据从底层传递到高层时,会进行一个逆向的解包过程。
- 数据链路层: 传输数据, 数据链路层需要将上层传递过来的数据报封装进 被称为帧的一个一个单元,其中每帧都会包含一个头,包含了目标地址和帧的大小。数据链路层在物理连接上 传输帧并处理来自接收者的确认。这一层可能进行 错误检测、重传、以及流量控制。一些数据链层还会将大的网络报分割成多个帧并在接收者端对这些帧进行重传。应用程序编程的角度通常可以忽略 数据链路层,因为所有的通讯细节都是由 驱动和硬件来处理的。有关IP的讨论中, 数据链路层中比较重要的一个特点是最大传输单元(MTU)MTU是该层 所能传输的帧大小的上限, 不同的数据链路层MTU是不同的(netstat -i )
- 网络层IP
网络层的、关注的为 如何将包 从愿主几发送到目标主机, 这一层执行的任务如下:- 将数据分解成足够小的片段一变数据链路层进行传输
- 在因特网上路由数据
- 为传输层提供服务
- 在TCP/IP 协议套件中, 网路层主要的协议是IP, 存在IPv4, IPv6版本。一个IP数据报包含了一个头,起大小范围为20字节到60字节,这个包中包含了的目标主机的地址,以及包的源地址。这样就可以在网络上讲这个数据报路由到目标地址了、以及接收方直到数据报的源头了。
- IP 是无连接协议、不可靠的协。 尽可能的将数据报从发送者传给接收者。当并不保证数据报到达的顺序以是否重传,甚至是否达到等。IP 也没有提供数据恢复。TCP/IP 的可靠性是通过使用一个可靠的TCP (传输层他协议)来保证的。
- IP 路径MTU: 原主机到目标主机之间的路径上的所有数据链路层的最小MTU (通常以太网的MTU 最小)当一个数据报的大小大鱼MTU时, IP会将数据报分段(分解成一个大小适合在网络上传输的单元, 这些分段在达到最终目标地址之后会被重组成原始的数据报), IP分段的发生对高层协议层是透明的,一般来讲并不希望发生这种事情。这里的问题在于IP并不进行重传,所以只有在所有分段到达目标之后才能对数据报进行重组,导致如果其中一些分段丢失可能导致整个数据报的失败,一些情况下会导致极高的丢包率(比如UDP并不会进行重传)或者降低传输速率(进行重传的TCP) 现在TCP实现采用了一些算法(路径MTU发现,这个是不是很简单的获取 IP的路径MTU?)并根据该值对传递给IP 的数据进行分解,来防止IP层对数据报进程分解。
- 传输层
- 端口号: 传输层协议的任务是向位于不同主机的上的应用程序提供端到端 通讯服务,所以传输层需要才用一种方法来区分一个主机上的应用程序,端口号的工作就是如此。(16位)
- UDP(用户数据报协议): 仅仅在IP上增加了: 1) 端口号, 2)进行检测传数据错误的 数据校验和, 因为IP是无连接的,而UCP并没有进行 可靠性的保证,所以UCP 具有同IP一样的特性
- TCP(传输控制协议): TCP 在两个端点之间提供了可靠的、面向连接的、双响字节流通信通道。通过如下几个方面来保证:
- 建立连接: 在连接期间,即 对交换数据 的通讯参数进行协商
- 将数据打包成分段: 将数据分解成段(使单个IP数据报封装成为可能,从而避免 IP层面 进行拆解)每个段都包含一个校验和,并使用单个IP进行传输,从而能够检出数据报的错误。
- 确认、重传、超时: 一个TCP段无错的到达接收方,接收方会向发送者发送确认请求,如果报发生错误,接收方丢弃即可。发送方 在发送每一个分段时会启用一个定时器,在定时器超时时没有收到确认。那么就重传这个分段 (由于所使用的网络以及当前的流量负载会影响 传输一个分段和接收其确认 所需要的时间,所以 TCP采用了 一个算法来动态的调整 重传超时时间(RTO) 的大小。接收者可能不会立即发送确认,而是等待几毫秒 来观察是否可以将 确认塞进接收者返回给发送者的响应中(因为相应是接收者发送给接收者的,并不需要传递确认信息)来减少一个TCP段的发送,从而降低网络中的包的数量。这个称为 延迟ACK 技术)
- 排序: TCP连接中的每个分段都会分配到一个逻辑号。这个数字指出了该分段在 该连接 的数据流中的位置(连接中的两个流都有各自的序号计数系统)序号的作用有: 1)这个序号可以保证TCP分段能够以正确的顺序在接收者进行组装, 然后以字节流的形式传送给应用层 2)接收者 发送给发送者 使用序号来标识出收到了那个TCP分段 3)接收者可以去除重复的分段信息。(一个流的初始序列ISN, 不是从0开始的,而是通过一个算法来生成的,该算法会递增分配给后续的TCP链接的ISN(防止前后的多个连接中 序号重复混淆的情况发生))
- 流量控制: 防止一个快速的发送者压垮一个慢速的接收者: 如何实现: 接收TCP 需要为进入的数据维护一个缓冲区(每个TCP在建立连接时候,都会告知其缓冲区大小)当从发送TCP端收到数据时会将数据放入到缓冲区中。当应用层读取数据时会从缓冲区中删除数据, 在每个确认中,接收者会通知发送者 其缓冲区的可用空间,TCP流量控制算法 采用了 滑动窗口算法,来允许包含N个字节的窗口大小的 未确认段 同时在 发送者与接收者之间传递,接收端的缓冲区被充满,那么窗口就会关闭,发送端就会停止传输数据。
- RFC(请求注解): 是由 国际互联网学会赞助的RFC编辑组织发布的,描述互联网标准的RFC是由互联网工程任务组资助开发的, 互联网工程任务组 是一个由 网络设计师、操作员、厂商以及研究人员组成的社区,主要关注互联网的发展和平稳运行。
服务器设计
- 迭代型: 服务器每次只处理一个客户端,只有当完全处理完一个客户端的请求后,才会去处理下一个客户端
- 并发型: 能够同时处理多个客户端的请求。 本章主要放在并发型服务器的传统设计方法:针对每个新的客户端连接, 创建一个新的子进程来处理,每个服务器子进程执行完所有服务于单个客户端的任务后就终止。因此可以同时处理多个客户端。
- 由于 服务器为每个客户端连接创建一个子进程,需要保证不会出现僵尸进程,所以需要为信号SIGCHLD 安装信号处理器。
- 主进程 主要由for 循环组成。在循环中accept 链接,然后fork 创建子进程, 在子进程中 调用hanldeRequest来处理客户端。(现实世界中,需要限制 服务器创建子进程的数量,大量的子进程会使系统变的不可用。)
- 每次fork后,监听套接字和连接套接字都在子进程中得到了复制。父进程 关闭 连接套接字, 子进程关闭 监听套接字。(如果父进程没有关闭 连接套接字的话 那么连接套接字永远不会被关闭,从而导致文件描述符被用完。监听套接字并不会这样,因为子进程 结束之后会释放所有的文件描述符)
- 每个子进程处理完客户端之后,终止
- 并发型服务器的其他设计:
- 预先创建进程或线程:
- 服务器在启动阶段就立刻 预先创建好一定数量的 子进程, 而不是针对一个新的客户端来创建 一个子进程。这些子进程构成了一个 服务池
- 每个子进程一次只处理一个客户端,在处理完之后,子进程并不终止,而是获取下一个待处理的客户端继续处理。
- 主进程需要仔细的管理子进程,并可以相应的根据负载来调节子进程的数量大小,此外, 子进程需要遵循某些协议,是的他们是以 独占 的方式来处理一个客户端的 连接套接字的。在大多数的UNIX实现中,让子进程 在监听套接字上调用accept即可,(即是: 主进程先创建 监听套接字,子进程在每个fork之后 继续使用套接字 并 accept,因为accept调用是一个原子化的操作,所以当客户端连接 到来时,之后一个子进程能够完成 accept调用。负载则由系统进行调度)
- 单个进程中处理多个客户端: 必须能够允许单个进程同时监听多个文件描述符上 IO事件 (IO多路复用, 信号驱动IO, epoll),单进程服务器需要做一些通常由内核来处理的调度任务。在 进程服务池 的设计中,我们可以依靠内核来确保每个服务器进程 能够公平的访问服务器主机的资源。但是当使用 单个进程处理多个客户端的方案时,服务器进程必须自行确保一个或多个 客户端不会霸占服务器,而使 其他的客户端处于饥饿状态
- 预先创建进程或线程:
- 服务器集群:
- DNS 轮询负载共享:一个地区的域名权威服务器将同一个域名映射到多个IP地址上(即 多个服务器共享统一域名)后续对DNS服务器的域名解析请求将以 循环轮转的方式返回这些IP地址,优势是成本低,存在的问题有:客户端 DNS缓存、 没有任何机制来达到良好的负载均衡、高可用 的机制 以及 无法确保同一个客户端的请求都到达同一台服务器(所以存在状态的服务器 需要在多个机器之间共享状态 这个特性成为 服务器亲和力)
- 负载均衡(server load balancing): 由一台负载均衡服务器将客户端的请求路由到服务器集群中的一个,这消除了 远端DNS 缓存所引起的问题。因为服务器集群只对外传递一个IP地址。负载均衡服务器会 结合一些算法来衡量或计算服务器负载 并智能化的 将负载分发到 集群中的各个成员上,可能还会提供对服务器亲和力的支持
- inted 守护进程:
- 守护进程 inetd 被设计为用来消除运行大量非 常用服务器进程的需要,inetd 优势有:
- 预期为每个服务运行一个单独的守护进程,现在只用一个进程 inetd 守护进程,就可以监视一组指定的套接字端口,并按照需要启动其服务,因为可以降低系统进程的运行数量
- inetd 简化了 启动服务的编程工作。
- inetd 进程:
- 读取 /etc/inetd.conf 文件,对于其中的每项服务,创建一个 恰当类型的套接字,然后绑定到指定的端口上,其中每个TCP套接字 都会通过listen 允许客户端请求连接
- 通过select调用, inetd 对前一步中创建的所有的套接字进行监听,看是否有数据报或者连接请求 进来
- select 进入阻塞状态: 直到一个UDP 数据报到来或 TCP 监听套接字 收到连接请求。TCP 会进行accept
- 启动套接字对应的服务,inetd 调用fork创建 一个新进程,然后调用exec 启动服务器程序,执行exec前,子进程执行如下步骤:这里面 子进程 代表的是 inetd 守护进程的标准执行过程, 所以需要执行步骤2,方便exec启动的服务器程序 使用标准文件描述符来 对客户端进行通讯
- 关闭从父进程继承的所有文件描述符,除了 用于监听的套接字
- 在文件描述符0,1,2上复制套接字文件描述符,并关闭套接字文件描述符本身,完成这一步之后,启动服务器进程就能通过 这三个标准 文件描述符 同套接字 通讯了
- 为启动的服务器进程设定用户、组ID (可选,通过/etc/inetd.conf 配置)
- 如果是TCP套接字,则关闭连接套接字
- 返回到 第 2步骤,继续执行
-
/etc/inetd.conf 配置文件:
service name socket type protocol flags login name server program server program argument ftp stream tcp nowait root /usr/sbin/tcpd in.ftpd telnet stream tcp nowait root /usr/sbin/tcpd in.telnetd login stream tcp nowait root /usr/sbin/tcpd in.rlogind
- service name (服务名称): 该字段为服务名称,结合 protocol 就可以通过查找 /etc/services 文件以确定 inted 问该服务监听的 端口号
- socket type (套接字类型): stream, or dgram
- protocol(协议): 该字段指定了 这个套接字所使用的协议,这个字段 可以包含文件 /etc/protocols 中所列出的任何的 internet 协议
- flags:
- wait or nowait 字段表明 由 inetd 启动的服务器 是否会接管 用于该服务的 监听套接字。 wait 表明 启动的服务器 需要管理 该监听套接字,inetd 将该套接字从它所监视的文件描述符列表中移除, 直到这个服务器程序退出为止。
- inetd 调用的 TCP 服务器通常被设计为 只处理一个单独的客户端连接,处理完后就终止, 把监听其他链接的任务留给inetd, 对于这样的服务器, flags字段应为 nowait, 相反 如果是被执行的服务器进程 来接受连接(accept)的话,那么该字段为wait, 此时inetd 不会去接受连接,而是将监听套接字 作为 文件描述符0 传递给 服务器进程。对于大部分的UDP 服务器, flags字段需要设定为wait, 由 inetd 调用的UDP 服务器设定为 读取并处理所有套接字上未完成的数据报,然后终止(通常需要一些超时机制)wait 可以组织 inetd在套接字上做select,会导致inetd 同 UDP 服务器程序之间,产生竞争条件,如果inetd 赢了,会启动一个新的 UDP 服务器实例
- login name(登录名) :该字段为 /etc/passwd 中的用户名组成部分,还可以在其后 添加 “:” + /etc/group 中的组名称, 这些确定了运行的服务器程序的用户ID和组ID, 因为inetd为 root 方式运行,所以 子进程同样可以是 特权进程。 因此可以在需要时 通过调用setuid, setgid 来修改进程凭证
- serever program (服务器程序): 指定了 被执行程序的服务器程序路径
- server program argument(服务器 程序参数): 该字段指定了 一个或多个参数, 参数之间由 空格符分隔,当执行服务器程序时,这些参数就作为程序的参数列表
- inetd 作为一个提高效率的机制,本身就实现了一些简单的服务,而无需单独的 服务器程序编码来完成任务, UDP, TCP 的echo 服务就是由inetd 来实现的一个例子。编辑修改 /etc/inetd.conf 之后,需要 killall -HUP inetd 发送SIGHUP信号来重新读取配置文件
- 守护进程 inetd 被设计为用来消除运行大量非 常用服务器进程的需要,inetd 优势有:
socket 高级主题
- 流式套接字 上的部分读和部分写
- read, write 系统调用 会产生部分读 和 部分写,在流式套接字上 更容易出现这样的问题。套接字傻姑娘可用的数据比read 读取的数量要少,那么read就会出现部分读 的现象,但是只是简单的返回读取的内容大小。write 调用在没有足够的缓冲区 来传输所有的字节时,并且 被信号处理函数中断 或 在 非阻塞模式下工作 或 TCP连接出现问题 write 会产生部分写的现象。 readn, writen 这两个函数 使用循环来 启用这些系统调用,总能确保 所有的数据都会被写入 或者读取。
- shutdown(int sockfd, int how) 系统调用
套接字 上调用close 将双向通讯通道 的两端都关闭,shutdown 提供了 更精细的控制 其中how的选项有;- SHUT_RD: 关闭连接的 读取端, 之后的读取操作将返回文件结尾,写入套接字操作依然可以进行。在UNIX domain 上进行 SHUT_RD, 对端的应用程序 将接受一个 SIGPIPE 信号,对端程序依然写入的话 将产生 EPIPE 错误, 对于TCP 套接字来说没有什么意义(需要在 61.6.6 中讨论)
- SHUT_WR: 关闭连接的 写端, 对端 会检测到 文件结尾。后续对套接字的读取操作会产生 SIGPIPE 信号以及 EPIPE 错误,而由对端的写入数据依然可以在套接字上正常读取。这个操作允许我们依然可以读取数据,并且告知对方写入已经完成。该操作在ssh 中rsh中 有用到,并称为 半关闭 套接字
- SHUT_RDWR: 将连接的读写端都关闭。等同于调用了 shutdown SHUT_RD, SHUT_WR
-
区分于close: shutdown 关闭的是系统级 文件表, 而非 进程文件描述符。 这意味着, 父进程 shutdown之后, fork 的子进程中的文件描述符 同样受到影响。需要注意的是, shutdown 并不会关闭进程文件描述符, 依然需要进程close来关闭文件描述符
fd2 = dup(sockfd); close(sockfd); // 之后依然可以在 fd2上进行IO操作 //-------- fd2 = dup(sockfd); shutdown(sockfd, SHUT_RDWR); // 之后无法在fd2 上sockfd 上 进行IO操作
- recv, send, 专属于 套接字的IO系统调用:
- recv(int sockfd, void * buffer, size_t length, inf flags) flags 的选项有:
- MSG_DONTWAIT: 使recv 以非阻塞方式执行, 没有数据可用 立即返回, 错误码为EAGAIN, 同样可以通过 fcntl 来把套接字设定为非阻塞方式运行, 区别在于,这个可以设定每次的调用的阻塞行为
- MSG_OOB: 在 套接字上接受带外数据
- MSG_PEEK: 从套接字数据缓冲区 获取一份请求字节的副本,但不会将数据从缓冲区中移除,这份数据可以在之后的read中重新读取
- MSG_WAITALL: 指定标记后, 将导致系统调用阻塞到 接收到length字节,但是总会出现返回的字节数少于 length的情况: 1)捕获到一个信号, 2)对端终止了连接 3) 遇到了带外数据字节 d)接收到的数据总长度小于 length, 4)套接字错误
- send(int sockfd, const void * buffer, size_t length, int flags)
- MSG_DONTWAIT: send 以非阻塞方式运行,如果 数据不能立即传送(发送缓冲区满时)该调用失败,错误码 EAGAIN
- MSG_MORE: 在TCP 套接字上,这个标记实现的效果同 套接字选项 TCP_CORK 完成的功能相同,区别在于该标记可以在每次调用中 对数据进行 栓塞 处理。
- MSG_NOSIGNAL: 指定该标记时,在已连接的套接字上发送数据时,如果连接的另一端已经关闭时,send 不会产生SIGPIPE信号,而是返回错误 EPIPE
- MSG_OOB: 在流式套接字 上 发送带外数据
- recv(int sockfd, void * buffer, size_t length, inf flags) flags 的选项有:
- size_t sendfile(int outfd, int in_fd, off_t * offset, size_t count):
传输文件的简单写法:while ((n = read(diskfilefd, buf, BUZ_SIZE)) > 0) write(sockfd, buf, n);
示例代码中 read 简单的将文件内容 从内核缓冲区cache中拷贝到用户空间,write将用户空间缓冲区拷贝到内核空间中的socket缓冲区。 sendfile 被用来减少这种操作的低效性。文件内容会直接传送到套接字上,而不会经过用户空间, 这种技术成为 zero-copy transfer 零拷贝传输
sendfile 函数调用的限制: out_fd 必须为套接字, in_fd 必须指向文件,能够进行mmap,这通常只能是一个普通文件。
- TCP_CORK 套接字选项: 为了提高TCP使用效率(linux专有的选项),在web服务器传送页面时候,作为请求的响应,通常由两部分组成, HTTP 首部, 页面数据,单独的使用write操作 会传输2个TCP报文段,一个非常小的HTTP报文放在第一个分段中,这对网络是非常浪费的。这时候使用TCP_CORK 来避免其低效性。当在TCP 套接字上启用 TCP_CORK 选项时,之后所有的数据都会穿充到一个单独的TCP 报文段中,直到满足以下条件为止: 以达到报文段的大小上限、取消了 TCP_CORK 选项、套接字被关闭、或者启用 TCP_CORK后,从写入的第一个字节开始已经超过200ms(防止忘记取消 TCP_CORK 选项,超时时间可以保证传输)下面例子 介绍如何使用 TCP_CORK:
optval = 1 setsockopt(sockfd, IPPROTO_TCP, TCP_CORK, sizeof(optval)); // enable TCP_CORK write(sockfd, ...); sendfile(sockfd, ...); optval = 0 setsockopt(sockfd, IPPROTO_TCP, TCP_CORK, sizeof(optval)); // disable TCP_CORK, TCP 开始传输
- 获取套接字地址:
- getsockname(int sockfd, struct sockaddr * addr, socklen_t * addrlen): 获取本地套接字地址, sockfd表示套接字的描述符, addr 为返回的套接字地址存储结构, addrlen 为 addr 结构的大小。在套接字并不是由自己初始化时,如 inet调用的应用程序只能获取 已经存在套接字,则可以通过该函数 获取 对应的绑定地址。
- getpeername(int sockfd, struct sockaddr * addr, socklen_t * addrlen): 获取对端的套接字地址, sockfd, 为对端的套接字描述符, 其他的同 getsockname 一致。在TCP 连接中,可以在accept时获取对端地址,但是如果服务器进程是由另一个进城调用的,比如inetd,那么这个函数就非常有用
- 内核隐式绑定的情况:
- 已经在TCP 套接字上执行了connect, listen调用,但之前并没有bind 调用到一个地址上
- 在UDP套接字上 首次调用sendto,盖套接字之前并没有bind到地址上
- 调用bind 时,将端口号 指定为0, 这种情况下 bind 会为套接字制定一个IP地址,并选择一个临时端口号
- getsockname(int sockfd, struct sockaddr * addr, socklen_t * addrlen): 获取本地套接字地址, sockfd表示套接字的描述符, addr 为返回的套接字地址存储结构, addrlen 为 addr 结构的大小。在套接字并不是由自己初始化时,如 inet调用的应用程序只能获取 已经存在套接字,则可以通过该函数 获取 对应的绑定地址。
- 深入探讨TCP 协议
- TCP 报文格式
- Source port number(源端口号): TCP 发送端的端口号
- Destination port number(目的端口号): TCP 接收端的端口号
- Sequence number(序列号): 报文的 序列号
- Acknowledgement number(确认序列号): 如果设定了ACK 位,那么这个字段包含了接收方期望从发送方接收到的下一个 报文的序列号
- Header length: 表示TCP 报文首部的长度,首部长度单位是32位,因为这个字段只有4个byte位,所以首部总长度最大可以达到 60字节, 该字段 是的TCP接收端可以确定变长的选项字段的长度,以及数据域的起始点
- Reserved(保留位): 该字段包含4个为啥喜欢i用的byte (必须设置为 0)
- Control bit(控制位): 8个byte组成:
- CER: 拥塞窗口减小标记
- ECE: 现实的拥塞通知回显标记 cwr & ece 标记用在TCP 的显示拥塞通知(ECN)算法中。 linux中 可以通过编辑文件 /proc/sys/net/ipv4/tcp_enc 设定一个非零值 来开启这个功能
- URG: 设定了该位, 紧急指针字段包含的信息是有效的
- ACK: 如果设定了该位, 那么确认序号字段包含的信息就是有效的 (可以同时包含对对端数据报的确认)
- PSH: 将所有收到的数据发给接受的进程 (RFC993)
- RST: 重置连接
- SYN: 同步序列号,在建立连接时,双方需要交换设定了该位的报文,使得tcp 连接的两段可以指定初始序列好
- FIN: 发送端提示已经完成了传送任务,TCP 连接关闭 - Window size (窗口大小): 滑动窗口机智有关, 用于 在ACK确认时 提示自己可以接受数据的空间大小
- Checksum(校验和)
- Urgent Pointer(紧急指针): 设定了该位置, 表示传送的数据位紧急数据
- Options(选项): 这是一个变长的字段, 包含了控制TCP连接操作的选项
- Data (数据): 包含了该报文中 传输的用户数据
- TCP 序列号 和 确认机制
每个通过TCP 连接传送的字节都由TCP 协议分配了一个逻辑序列号,双向数据流都有各自的序列号,当传送一个报文时,该报文的序列号被设为该传送方向上的 报文段数据域的第一个字姐的逻辑偏移。这样接收端 就可以按照正确的顺序对接收到的报文进行重组了。TCP 采用了主动确认,当一个报文段被成功接收后, 接收端会发送一个确认信息 即发送ACK 确认报文 给发送端,该报文的 确认序号字段被设置为 期望接受的下一个数据字节的逻辑序列号 (上一个成功收到的序列号 + 1 )TCP 发送端发送报文时会启动一个定时器,如果在定时器超时时,仍未收到确认报文,那么就重传该报文 (注意 序列号并非常简单的递增 1,而是 按照 传送报文数据 大小来递增的 如下图)
- TCP 连接的建立: API 层面: 服务器)调用listen 打开套接字,然后accept, 阻塞服务器进程 直到连接建立完成。客户端)调用connect 同服务器打开的套接字 建立连接
- 客户端 TCP节点 发送一个SYN 报文到服务器 TCP端,这个报文将告知 服务器有关客户端的TCP节点的初始序列号 (因为序列号不是从0 开始)
- 服务器 TCP端 发送确认 客户端 SYN报文的 ACK报文,并同时携带 SYN 的序列号。 即发送ACK,SYN 报文
- 客户端 TCP 节点发送一个ACK报文 来确认服务器端的TCP SYN 报文
- TCP 连接的终止: 一端的应用程序执行close 调用 (主动关闭), 之后 连接另一端的应用程序 也执行close调用(被动关闭)下面的报文顺序为假设 客户端 发起主动关闭
- 客户端执行主动关闭, 导致客户端TCP节点 发送一个FIN报文到服务器端
- 服务器端收到FIN 报文后,发送 ACK 报文进行响应。(之后服务器端任何对 套接字 read 操作 的尝试都会读取到 文件结尾)
- 稍后, 当服务器关闭 自己端的 连接时,服务器端 TCP 节点发送 FIN报文到客户端
- 客户端TCP 节点发送ACK报文作为响应
上面讨论的是 close 全双工的关闭(连接虽然是双向的,但是TCP节点的状态是唯一共享的), 然而系统调用允许 shutdown 调用来关闭其中的一个通道,使TCP 成为 一个半双工。我们使用 shut_rdwr, SHUT_WR 来调用 shutdown 时候,TCP 连接将开始上面的关闭步骤。本地的 TCP节点 迁移到FIN_WAIT1 状态, 然后进入 FIN_WAIT2 状态,对端的进入到 CLOSE_WAIT 状态, 如果参数为 SHUT_WR 那么 套接字依然合法(合法的定义是? 某种符合条件的状态? FIN_WAIT1, FIN_WAIT2 正好处于 接受对端的ACK 报文, 而没有收到对方FIN 的状态,即 自己主动关闭成功,对方并未关闭),读端依然是打开的,因为对端的写入操作依然可以进行。这里 SHUT_RD 在TCP套接字上没有实际意义的原因是因为, 大多数TCP协议的实现都没有为 SHUT_RD 提供所期望的行为。导致该参数调用的shutdown 并不具有可移植性
- TIME_WAIT 状态: 执行主动关闭的TCP 端在该状态下, 等待 2MSL时间,然后迁移到closed 状态。 这样的设计目的有两点:(该状态应该只存在于 主动关闭的 TCP 节点上,主要原因在于, 主动关闭的节点 需要对上一条 别动关闭的节点 的FIN报文响应 ACK报文,并组织新的连接的创建 来 保证TCP 可靠性连接的建立)
- 实现可靠的链接终止:等待2被的MSL, 这里的MSL 是TCP报文最大生存时间
- 让老的重复的数据报文段在网络中过期失效:TCP协议采用的重传算法,可能会产生重复的报文,根据路由器选择,这些重复的报文可能会在连接已经终止后到达, 而之后使用同样的IP端口重新建立的连接, 然后接收到的数据报 在这种情况下, 为了保证上一次连接中老的重复报文不会重复的出现在新的连接中被当成合法报文接收。当有TCP节点处于TIME_WAIT 状态时 是无法通过 该节点 创建新的连接的。这样就组织了新链接的建立。(在论坛上会看到相关的 如何关闭 TIME_WAIT 状态,因为重新启动的服务器进程会尝试将套接字绑定到处于 TIME_WAIT 状态的地址上时候,会出现 EADDRINUSE 的错误,尽管有办法关闭 TIME_WAIT状态 但是还是应该避免这样做, 因为会阻碍 TCP 提供可靠性保证)
-
netstat(监视套接字)
netstat 程序可以显示系统中Internet 和 UNIX域套接字的状态, 是一个非常好的调试工具, 大多数的Unix 都会提供一个版本的 netstat, 但是各个实现中的命令行参数语法有很大的区别
其中展示的信息中的字段含义列表有:更多的细节需要查阅netstat用户手册, /proc/net中有多个专属Linux 的文件,例如tcp, udp, 的呢个,获取更多细节,参考 proc字段名称 含义 Proto 套接字所使用的协议 Recv-Q 套接字接收缓冲区中还未被本地应用读取的字节数(UDP 中该字段不仅仅包含数据还包含头部信息等) Send-Q 套接字发送缓冲区中排队等待发送的字节数(同Recv-Q 一样 UDP) Local Address 套接字绑定到的地址, 主机IP:端口号 展现形式 Foreign Address 对端套接字锁绑定到的地址, *:* 表示没有对端地址 State 当前套接字所处的状态 - tcpdump: 有用的调试工具,可以让超级用户监视网络中的 实时流量,实时生成文本信息, 可以显示所有类型的TCP/IP 数据报流量。显示方式如下(具体的使用细节需要google):
src > dst: flags data-seqno ack window urg- src, dst: 源 IP 地址和端口, 目的 IP 地址 和端口号
- flags: TCP 报文控制位 信息, 他们是 S(SYN) , F(FIN), P(PSH), R(RST), E(ECE), C(CWR) 中的标记位组合
- data-seqno: 数据报中的序列号范围
- ack: ack num,对端 期望的下一个方法字节的序列号
- window: 对端缓冲区大小(存在接收、写入缓冲区两种)
- urg: urg num 该报文 在指定的偏移量上包含紧急数据
- options: 任意的TCP选项
- 套接字选项:
- int getsockopt(int sockfd, int level, int optname, void * optval, socklen_t optlen); int setsockopt(int sockfd, int level, int optname, const void * optval, socklen_t optlen) 两个系统调用获取 设定 套接字选项
- SO_REUSEADDR 套接字选项: 该选项主要应用在 服务器bind 地址时候,出现EADDRINUSE 错误(即存在TCP节点处于 TIME_WAIT 状态)出现该现象的情况有:
- 之前连接到 客户端的服务器 被close, 或者是 崩溃 而执行了一个主动关闭,这就使得 TCP节点处于 TIME_WAIT状态, 直到 2倍的MSL超时 过期为止
- 服务器创建一个子进程来处理客户端的请求, 稍后,服务器终止, 而子进程继续服务客户端, 因为使得 TCP 节点占用了服务器的 端口号
针对以上情况,默认的TCP实现会阻止 新的监听套接字的绑定, (客户端不常出现这样的错误, 因为他们一般使用的是不会处在 TIME_WAIT 的临时端口号(新建的连接会选择新的端口号),但是如果客户端指定的保定到一个端口上那么还是会遇到这个问题) - (类比并不严谨)accept 需要识别出 一个新来的套接字,系统内 记录的大概内容是 [ {local-ip-Address, local-port, Foreign-ip-address, Foreign-port }] 每个 已连接的套接字对应的对端的一个套接字,当有链接对应的接入时,通过对端的 TCP 报文 中对应的 ip port 可以识别出对应的 本地的 已经接套接字,如果没有对应的连接套接字存在 则 accpet 创建新的 连接套接字。 TCP 规范要求这个4 元祖是唯一的,问题在于大多数的 实现 都强制限制了 一个更为严格的约束,如果本地存在 可以匹配到本地端口 TCP连接, 则本地端口不能被重用 (即 不能bind 该 端口号)也不能在接受新的链接(即不能accept) 启用该socket选项可以放开这个限制,即便是 情况 2 我们依然可以绑定到该地址,大多数的TCP 服务器应该启用这个选项。
- UDP 相对TCP 的优势有: 1)UDP 服务器可以从多个客户端接受数据报,而不必为每个 客户端 创建和终止连接 2) 对于简单的请求: UDP的速度更快, 因为UDP不需要建立 和终止连接. DNS 是一个 应用UDP的绝好例子。
- 传递 文件描述符: 通过sendmsg, recvmsg 系统调用,我们可以在同一台主机 上通过UNIX 域套接字 将文件描述符 辅助数据 从一个进程传递到 另一个进程中。 这种方式可以传递任意类型的文件描述符(open 得到的文件描述符, 套接字)这个可以应用在服务器的并发模型中, 主进程可以在TCP监听套接字上接收客户端连接,然后将返回的文件描述符传递给进程池中的一个,之后子进程就可以响应客户端的请求了(虽然这种技术通常称为 传递文件描述符, 但实际上 进程间 传递的是对同一个打开文件描述符的引用。在接收端进程中使用的文件描述符一般和发送进程中文件描述符不同)
- TCP 报文格式
其他 的IO模型: IO多路复用, 信号驱动IO, Linux专有 epoll
大部分的程序使用的IO 模型都是单个进程 在多个文件上执行阻塞IO,例如 在管道上调用read,如果管道 中没有数据,那么read会阻塞到 直到管道中有数据,才会继续执行后续的工作。(磁盘文件是一个特例, 对磁盘的write 会立即返回,而不是等到将数据写入到磁盘文件上之后才返回。对应的read 如果数据不在缓冲区内,内核会休眠该进程 (内核 缓存页 调度)然后再 继续read)
- 问题: 然而 依然存在下面的需求:
- 以非阻塞的方式检查文件描述符上是否可以进行IO操作
- 同时检查多个文件描述符,查看其中一个是否可以进行IO 操作
现有的系统调用 部分的可以满足这两种需求: 多线程和IO非阻塞(对打开的文件描述符设定 O_NONBLOCK) 非阻塞 IO 可以使我们周期性的检查 某个文件描述符是否可以进行IO操作, 那么同时操作多个IO文件描述就有下面的几种方式:- 周期性的对多个文件描述进行检查,即轮询,这种轮询通常是我们不希望看到的,轮询频率不高的话,应用程序对IO延迟可能会非常高, 频率高的话,则非常浪费CPU
- 多线程、多进程 的方式进行多个文件描述符的操作: 即 分离开 一个单独的进程、线程来 操作 单个文件描述符,分离多个即可操纵多个文件描述符。这样的弊端在于,多进程 会占用太多的系统资源,多线程、多进程依然需要进行进程、线程间的沟通,导致编码复杂(多线程尤为复杂繁琐)
- 于是有下面几种备选的 IO方案(其共同解决的问题是: 同时检查多个文件描述符,以检查是否可以进行IO操作。文件描述符的就绪转换是通过一些IO事件来触发的。(比如 输入数据的到达、套接字建立的完成、tcp数据报被传送之后,缓冲区有了更多的剩余空间等)同时检查多个 文件描述符对服务器程序,以及同时检查 终端、管道、套接字等程序 是非常有帮助的)
- IO 多路复用: 允许进程同时检查多个文件描述符 以找出他们中一个可以执行IO操作, 系统调用 select poll 可以用来执行IO多路复用
- 信号驱动IO: 当输入或者数据可以写到指定的文件描述符时候,内核向进程发送一个信号,进程通过接收到信号之后来处理IO任务。当检查大量的文件描述符时 相对于 select, poll 等可以显著的提升性能
- epoll(Linux专有的特性): 具有多路复用、信号驱动IO 的优点, 允许同时检查多个文件描述符, 当检查大量的描述符时候,依然有很好的性能表现
- 总结来说: epoll 相对于select具有性能优势,对比 信号驱动IO 具有避免了信号处理的复杂性。唯一的缺点在于 其为linux专有,没有很好的可移植性
- Libevent 提供了一个检查文件描述符IO 事件的抽象,Libevent的底层机制能够以透明的方式 应用select, poll, 信号驱动IO, epoll。项目地址 当然是通过google更简单啦
- IO 通知模式:
- 边缘触发:文件描述符自 上次状态检查以来有了新的IO活动 (比如新的输入)此时需要边缘触发通知。采用边缘触发通知意味着 只有当IO事件发生时 才会收到通知,而且我们并不知道需要处理多少IO字节,所以采用边缘触发通知的程序需要按照如下规则设计:
- 再接收到一个IO事件通知后: 程序应该在某个时刻 在相应的文件描述符上尽可能多的执行IO,如果程序没有这么做,则可能失去执行IO 的机会,因为在另一个IO事件到达之前,程序不会再接收到通知了,前面我们说 在某个时刻 的原因在于,接收到通知时候后,可能并不适合立刻执行IO操作,更深层次的原因在于,如果我们仅对一个文件描述符进行IO操作,可能会让其他的 文件描述符处于饥饿状态。
- 文件描述符需要设定为 非阻塞模式, 因为 第一条,每次IO操作需要进行尽可能多的IO操作
-
水平触发: 如果文件描述符上可以非阻塞的执行IO系统调用,此时认为他已经就绪,采用水平通知时: 意味着我们可以随时检查文件描述符的就绪状态,水平触发模式允许我们在任意时刻重复检查文件描述符IO状态,没必要 每次的那个文件描述符就绪后就尽可能的执行IO操作
IØ 模式 水平触发 边缘触发 select, poll 支持 不支持 信号驱动 IO 不支持 支持 epoll 支持 支持
- 边缘触发:文件描述符自 上次状态检查以来有了新的IO活动 (比如新的输入)此时需要边缘触发通知。采用边缘触发通知意味着 只有当IO事件发生时 才会收到通知,而且我们并不知道需要处理多少IO字节,所以采用边缘触发通知的程序需要按照如下规则设计:
- IO 多路复用:允许我们同时检查多个文件描述符, 查看其中一个是否可以执行IO操作。两个系统调用select, poll
- int select(int nfds, fd_set * readfds, fd_set * writefds, fd_set * exceptfds, struct timeval * timeout):
- readfds: 检测输入是否就绪的文件描述符集合
- writefds: 检测输出是否就绪的文件描述集合
- exceptfds: 检测异常情况是否发生的文件描述符集合。 这里的异常情况指的是: 连接到信包模式下的伪终端主设备上的从设备状态发生了变化, 流式套接字上接受到了带外数据
- fd_set 的操作方式: 四个宏实现: fd_zero(将fdset 初始化为空), fd_set(将文件描述符fd添加到 fdset中), fd_clr(将文件描述符fd, 从fdset中移除)fd_isset(检查fd是否是fdset中的一员)fdset又一个最大容量限制, 常量 FD_SETSIZE 决定, 在linux通常为 1024, readfds, writefds, exceptfds 同时也是 保存结果的地方,在调用select之前, 参数需要初始化 为感兴趣的文件描述符集合。select之后 这些结构就是就绪状态的文件描述符集合了。因为参数被修改,所以再次调用select 需要保证每次重新初始化它们。fdset 对应的参数可以指定为NULL, 参数 nfds 需要设定为比所有fdset中文件描述符更大的 + 1, 来供select 过滤掉比这个值更大的文件描述符 (如果是这样的话, 岂不是 select 并没有 直接检查fdset中的 文件描述符)
- timeout: 参数控制着select 的阻塞行为
- timeout指向的结构体都为 0: select不会阻塞,只是简单的轮询指定的文件描述符集合,是否存在就绪的文件描述就立刻返回
- NULL:select会一直阻塞 直到下面的事件发生: 1)readfds, writefds, exceptfds 种植定的文件描述符中至少有一个 成为就绪状态, 2) 该调用 被信号处理函数中断, 3) timeout 中指定的时间上线超时
- Linux 无论 select因为一个或多个文件描述符就绪而返回 或者 被信号中断 返回, timeout非NULL的话,timeout指向结构体都会被修改 表示剩余的超时时间。, SUSv3 规定timeout指向的结构体只有select成功之后才有可能被修改
- 返回值:
- -1: 表示有错误发生,有 EBADF 表示readfds,writefds,exceptfds 中又一个文件描述非法, EINTR 表示被信号处理器中断
- 0: 表示 任何文件描述符成为就绪状态前 select调用已经超时, 其中fdset的参数都会被清空
- > 0: 表示处于就绪态的文件描述符个数, 如果一个文件描述符 在readfds, writefds, exceptfds中存在多次,则会被多次统计, 即 数值表示的, 3个fdset中的就绪状态的文件描述符之和
- int poll(struct pollfd fds[], nfds_t nfds, int timeout): struct pollfd {int fd; short events; short revents; }
- pollfd 结构体中 的events & revents 字段都是 位掩码, 调用者厨师胡嘉爱events来之定需要为描述符fd做检查的事件, poll 返回时, revents 被设定为 该文件描述符实际上发生的事件
- events 0: 表示对该文件描述符上的事件 不感兴趣,同时 revents字段总是返回0, fd 设定一个负值 会产生同样的效果, 两种方法都可以用来关闭对单个文件描述符的检查
- timeout 设定了 poll的阻塞行为,如下:
- -1 : poll会一直阻塞知道fds数组中列出的文件描述符有一个达到就绪态 或者 捕获到一信号
- 0: poll 不会一直阻塞,只是检查是否有处于就绪态的 文件描述符
- > 0: poll 至多阻塞timeout毫秒,直到fds 列表中 的文件描述符有一个达到就绪态, 或者 捕获到一个信号
- 返回值:
- -1 表示有有错发生, 一种可能的错误为 EINTR, 表示该调用被一个信号处理器中断, 并且poll 不会自动恢复
- 0 表示 在文件描述符就绪之前超时了
-
> 0: 表示就绪态的 pollfd 结构体数量 (比较于 select, 这里并不会出现重复统计的问题)
位掩码 events 中的输入 返回 revents 描述 POLLIN * * 可读取非高优先级的数据 POLLRDNORM * * 等同于POLLIN POLLRDBAND * * 可读取优先级数据 (linux中不使用) POLLPRI * * 可读取高优先级数据 POLLRDHUP * * 对端套接字关闭 POLLOUT * * 普通数据可写 POLLWARNORM * * 等同于 pollout POLLWRBAND * * 优先级数据可写入 POLLERR * * 有错误发生 POLLHUP * * 出现挂断 POLLNVAL * * 文件描述符未打开 POLLMSG * * Linux中不使用
- 文件描述符何时就绪?: select 使用简单的 w(可写), r(可读), x (异常)poll, 使用revents 的位掩码
- 普通文件 select总是标记为可读可写, 对于poll来说 则在revents中返回 POLLIN | POLLOUT (因为read总是立即返回数据, write 总是立刻传送数据)
-
管道和FIFO:
管道中有数据? 写端打开了吗? select Poll 否 否 r pollhup 是 是 r pollin 是 否 r pollin pollhup 一些UNIX 实现中,如果管道写端是关闭状态,那么poll 返回POLLIN (因为read遇到的结尾) 可移植性的程序应该同时检查 两个标志 来知道read 是否阻塞了
有 PIPE_BUF 个字节空间吗? 读端打开了吗? select Poll 否 否 W pollerr 是 是 W pollout 是 否 W pollout pollerr 一些UNIX 实现中,如果管道读端是关闭状态,那么poll 返回POLLOUT、POLLHUP 可移植性的程序应该同时检查 三个标志 来知道read 是否阻塞了
-
套接字上面的表现
有 PIPE_BUF 个字节空间吗? select Poll 有输入 r pollin 有输出 w pollout 监听套接字上建立连接 r pollin 接收到带外数据 x pollpri 流套接字的对端关闭连接 或执行了shutdown(SHUT_WR) rw pollin | pollout | pollrdhup Linux专有的pollrdhup标志, 实际上是epollrdhup,主要设计用于epoll api 的边缘触发模式下, 当流式套接字连接远端关闭了写连接时候会返回该标志,能够让采用了epoll 边缘触发模式的应用程序 更简单的判断远端是否已经关闭
- select vs poll
- 实现: 都使用了相同的内核poll例程集合, 这些poll例程有别于系统调用poll本身,每个例程 都返回 有关单个文件描述符就绪的信息, 这个就绪信息以位掩码的形式返回,其数值类似于 poll 系统调用中的 revents字段。系统调用的实现包括为每个文件描述符 调用内核poll 例程, 并将结果处理成 系统调用 的返回结果
- 区别:
- 被检查文件描述符的数量限制: select 对于被检查的文件描述符有一个上限 限制, 在Linux上,这个上限默认为1024, 修改上限需要重新编译程序,poll 在数量上没有限制
- select 参数宏 fdset 同时也是 返回调用结果的地方, 如果需要在循环中 重复调用select, 那么我们每次都需要 重新初始化 fdset, 而poll 则不需要,因为 其通过两个独立的字段,events, revents
- select 提供的超时精度 (微秒) 比 poll 提供的超时精度(毫秒)要高 (当然都受到 软件时钟粒度的限制)
- 被检查的一个文件描述符关闭了, poll会准确的告知 哪一个文件描述符, select只会返回 -1, 设定错误码为EBADF (只能通过轮询调用错误码来识别是哪个文件关闭了)
- 问题: 两个系统调用 是可移植的、长期存在 并被广泛使用的。但是在检查大量的文件描述符时,都会存在性能问题, 问题的原因如下:
- 每次调用 select, poll,内核都必须检查所有被指定的文件描述符
- 系统调用中参数的传递; select poll 每次都必须 传递所有需要被检查的文件描述符 信息到内核中,内核检查之后,修改这些结构返回给调用者,当需要检查大量的文件描述时,从用户空间到内核空间的数据拷贝 寄哪个占用大量的CPU时间。对于select 来说,还必须在每次调用前,对数据结构进行初始化。
- 系统调用返回后,需要 遍历结构中的每个元素,来确定就绪的文件描述符
- 根本原因在于 API的局限性: 通常程序会重复调用这些系统调用,并且其检查的文件描述符都是相同的,可是内核并不会记录这些
- 每次请求跟主动通知,的对象、消息 构造的依赖关系、编程模式,造就了 性能上的根本表现
- 信号驱动IO: 在信号驱动中,进程请求内核 在 文件描述符可执行IO 操作时为进程发送一个信号,之后 进程 就可以通过信号处理器 来 得知 文件描述符的变化
- 使用信号驱动的程序 应该按照如下方式 来编程:
- 为内核发送的通知信号安装一个信号处理器, 默认情况下, 该信号为 SIGIO
- 设定进程成为文件描述符的属主(通过fcntl 来完成). 文件描述符的属主: 当文件描述符上可执行IO 时 会接收到信号通知的进程 或进程组
- 设定 O_NONBLOCK 是文件描述符成为非阻塞IO
- 设定 O_ASYNC 标志 使能 信号驱动 (同上 使用fcntl 来进行操作)
- 进程可以执行其他任务, 当IO操作就绪,内核为进程发送一个信号后执行安装好的信号处理器
- 信号驱动IO为边缘触发通知, 意味着 当进程接收到IO就绪通知,需要尽可能多的执行IO操作(读写更多的字节)直到 IO 返回的错误码 为EAGAIN, EWOULDBLOCK为止
- 何时发送 IO就绪 信号:
- 管道, FIFO 读端, 在以下情况会产生信号:
- 数据写入到管道中
- 管道的写端关闭
- 管道, FIFO 写端, 在以下情况会产生信号:
- 对管道的、读操作增加了管道中的空余空间大小, 因此可以写入PIPE_BUF 个字节,而不会被阻塞
- 管道的读端关闭
- 套接字:
- 数据报
- 一个数据数报文 到达 套接字 (即使已经存在未读取的数据报文)
- 套接字上发生了异步错误
- 流式套接字
- 监听套接字上 接收到了新的连接
- TCP connect 请求完成,对于UNIX domain 不会产生信号
- 套接字上接收到了新的输入 (即便已经有未读取的数据存在)
- 套接字对端使用了shutdown关闭了写连接(半关闭),或者close 完全关闭
- 套接字上输出就绪
- 套接字上发生了异步错误
- 优化信号驱动IO: 对比 select, 检查大量的 文件描述符时,具有显著的性能优势, 之所以能够达到这么高的性能是因为内核 记录 了需要检查的文件描述符, 且当IO事件实际发生时 才会向程序 发送信号, 结果就是 采用 信号驱动IO的程序性能呢个可以根据饿发生IO事件的数量来扩展, 而与被检查文件描述的数量无关. 优化使用IO的 操作:
1. 专属LInux的fcntl(F_SETFIG) 来之定一个实时信号, 当文件描述符上的IO就绪时, 这个信号 被取代 SIGIO 发送
2. 使用sigaction 为上一步指定的 SA_SIGINFO 信号 安装信号处理器 使用siginfo 来代替 sigio 是必须的, 原因有 : 1)signio是标准的非排队信号之一, 如果有多个IO事件通知,sigio被阻塞了(比如 第一个信号处理器执行中)导致后续的通知会丢失,如果指定一个 SIGINFO 实时信号,那么多个通知就能够排队处理, 2)使用sigaction 来安装信号处理器, 且在sa.sa_flags = SA_SIGINFO 时候,内马尔结构体siginfo_t 会作为第二个参数传递给信号处理器,这个结构体包含了 哪个文件描述符发生的事件 以及事件类型 (对于IO就绪事件,传递给信号处理器的结构相关字段如下: si_signno: 引发信号处理器得到调用的信号值, si_fd: 发生IIO事件的文件描述符, si_code: 发生事件类型 的 代码,si_band: 一个位掩码同系统调用poll中revents相同, 与si_code 一一对应)
3. 在一个纯粹的 输入驱动的应用程序中, 我们可以阻塞 IO就绪 信号,通过sigwaitinfo,sigtimedwait 来接收 排队中的信号,返回的 signinfo_t 结构体一样。以这种方式接受喜好,实际上是在同步处理IO事件, 对比与 select poll, 这种方式依然可以有效的获取IO事件
4. 信号驱动IO中 有排队信号溢出的风险,导致我们会失去一些IO的信号消息,一个好的设计应该是, 同时对 SIGIO 设定信号处理器,在处理器中 通过sigwaitinfo 将队列中的实时信号全部获取,然后临时切换到 select, poll,来处理剩余的IO事件的文件描述符 (?这里为什么这么做? 需要了解信号相关的处理机制以及 系统调用 sigwaitinfo)
5. 多线程中使用信号驱动IO: fcntl 可以指定一个线程 来作为IO就绪信号的接受者
- 优化信号驱动IO: 对比 select, 检查大量的 文件描述符时,具有显著的性能优势, 之所以能够达到这么高的性能是因为内核 记录 了需要检查的文件描述符, 且当IO事件实际发生时 才会向程序 发送信号, 结果就是 采用 信号驱动IO的程序性能呢个可以根据饿发生IO事件的数量来扩展, 而与被检查文件描述的数量无关. 优化使用IO的 操作:
- 数据报
- 管道, FIFO 读端, 在以下情况会产生信号:
- epoll 编程接口
- 优点有: 1) 当检查大量的文件描述符时,epoll的性能延展性比select和poll高很多, 2) epoll API 同时支持 水平触发、边缘触发, 3)比较于信号驱动IO: 可以避免复杂的信号处理流程、更高的灵活性,可以指定我们希望检查的事件类型
- epoll 的核心数据结构为 epoll实例, 他和一个打开的文件描述符相关联,但是该文件描述并不是用来做IO使用的。踏实内核数据结构的句柄,该内核数据结构 记录了: 1)在进程中声明过 感兴趣的 文件描述符列表(interest list) 2) 维护了处于IO就绪态的 文件描述符列表 (ready list)
- 使用方法:
- int epoll_create(int size): 系统调用 创建了一个epoll 实例,其中感兴趣的列表初始化为空。调用返回 新创建 的epoll 实例的文件描述符(后续的几个函数调用都操作该文件描述符,无用时,应该close该文件描述符, 当所有与epoll实例相关的文件描述符都被关闭时,实例销毁 (当使用fork, dup等导致epoll的文件描述符存在多个时))。其中size有些鸡肋,指定了我们想要使用epoll 感兴趣文件描述的个数,但是该参数并不是上线,而是可以动态增加的,size的作用在于告知内核为内部数据结构初始化内存大小。
- int epoll_ctl(int epfd, int op, int fd, struct epoll_event * ev): 修改epoll的兴趣列表,epfd 为上个调用产生的 文件描述符, fd 为感兴趣的文件描述符,该文件描述符甚至可以是另一个epoll的实例的文件描述符(因为可以构建一个层级的 关系)但是不能是普通文件的描述符。 op的可选项如下:
- EPOLL_CTL_ADD: 添加 fd到, epoll实例的感兴趣列表中,其感兴趣的事件,都指定在了ev的结构体中 (添加一个已经存在的感兴趣文件描述符 出现eexist 错误)
- EPOLL_CTL_MOD: 修改 文件描述符 fd上的事件结构,(不存在于感兴趣文件描述符中 出现enoent错误)
- EPOLL_CTL_DEL: 将fd从epoll实例的感兴趣列表中移除, 操作忽略ev参数
- ev的结构如下:
struct epoll_event { uint32_t events; // epoll的位掩码,指定了我们感兴趣的事件集合, epoll_data_t data; // data 是一个联合体(union) , 当fd成为就绪态,该联合体被传递给调用进程 }
- max_user_watches: 每个epoll实例感兴趣的文件描述符 都需要占用内核空间(一小段不能被交换的内核内存空间?)内核提供了一个接口用来定义可以注册到 epoll实例上的文件描述符总数(每个用户or每个进程?)可以通过 max_user_watches 来修改和查看。Linux系统的 /proc/sys/fd/epoll 目录下的一个文件(是否可以修改?)
- int epoll_wait(int epdf, struct epoll_evnet * evlist, int maxevents, int timout): 返回epoll实例中处于就绪态的文件描述符。evlist 是一个包含 有关就绪态文件描述符的数组([epoll_event]) 其内存空间由 调用者 负责申请,maxevents 为 evlist中包含的元素大小。返回的ep_events中的 events 为已经发生的事件掩码,data字段为 添加到epoll 实例感兴趣列表中时候 的ev 结构中的data,data 结构体用来获取 就绪文件描述,所以在 添加到感兴趣列表中 应该 指定ev.data.fd 为文件描述符或者 ev.data.ptr 来获取到文件描述符。参数 timetout, -1 调用一直阻塞到直到有就绪态文件描述符时,返回, 0: 执行一次非阻塞检查,查看感兴趣列表中是否有有就绪态的文件描述符, > 0: 阻塞调用最多等待 timeout 毫秒,否则超时。 因为epoll实例为文件描述符,所以可以实时更新 感兴趣列表, 无论是在多线程、多进程中
-
epoll事件:
bit mask epoll_ctl 输入? epoll_wait返回? desc epollin - - 可读取非高优先级的数据 epollPRI - - 可读取高优先级数据 epollRDHUP - - 套接字对端关闭 epollOUT - - 普通数据可写 epolllet - 边缘触发事件通知 epolloneshot - 在完成事件通知之后禁用检查 epollerr - 有错误发生 epollhup - 出现挂断
epolloneshot 比较奇怪: 指定之后 只会收到一次 该文件描述符的通知, 之后该文件描述符被标记为非激活状态,意味着之后的epoll_wait 再也不会收到该文件描述符的通知,需要 EPOLL_CTL_MOD 在此激活才可以恢复 (不能使用 EPOLL_CTL_ADD 因为 该文件描述符依然在epoll 中的感兴趣列表中)
- epoll 文件描述符: 通过 epoll_create 创建一个epoll实例, 内核会创建相应的i-node 以及对应的 系统级别的 文件描述符,随后在 调用进程中 创建新的文件描述符, 同epoll实例的兴趣列表相关联的 是系统级的文件描述,而不是 epoll文件描述符, 这将产生如下的现象:
- dup 或 fork 复制一个epoll文件描述,那么复制后的文件描述符所指代的epoll兴趣列表 同 epoll的文件描述符一样,一端的epoll操作同步到另一端
- epoll 兴趣列表中的成员 同样在只有完全关闭(所有的进程级别的文件描述符都关闭)之后才会从 epoll的兴趣列表中自动移除
-
与select, poll 的性能对比 (随着 被监控(感兴趣)的文件描述符数量的上升, select poll的性能 表现越来越差,而epoll的性能表现表现几乎没有降低)
感兴趣的文件描述符数量 poll CPU 时间(s) select CPU 时间(s) epoll CPU 时间(s) 10 0.61 0.73 0.41 100 2.9 3.0 0.42 1000 35 35 35 10000 990 930 0.66 - epoll 性能为什么会更好?:
- 与select, poll相比 每次需要检查所有 在调用中指定的文件描述符, 通过epoll_ctl 添加文件描述符 到epoll感兴趣的文件描述符列表之后,每当执行IO操作使得 文件描述符成为就绪态时,内核就在epoll描述符的就绪列表中 添加一个元素, 之后epoll_wait 调用从 就绪列表中简单的读取这些数据
- 每次调用select poll时,我们传递所有需要监视的文件描述符列表 到内核中,内核将所有 标记为就绪态的文件描述符 数据传递过来。epoll 使用epoll_ctl 在内核空间中构建一个数据结构, 该数据结构记录 感兴趣的文件描述符,之后调用epoll_wait 并不需要传递 文件描述符相关的数据
- select poll 随着被监视文件描述符的数量 而扩展,epoll则随着 发生IO事件 文件描述符 的数量而扩展。常见的高效的服务器 会同步处理许多的客户端,需要监视大量的文件描述符,但是大部分处于空闲状态,只有少数文件描述符处于就绪态
- 边缘触发通知: 默认情况下, epoll提供的为水平出发通知,意味着 何时 在文件描述 上非阻塞的执行IO操作。epoll还能够采用同信号驱动IO一样的模式即是: 边缘触发通知。只是如果 有多个IO事件发生的话,epoll会合并成一次单独的通知,并通过epoll_wait返回。而信号驱动IO则会产生多个信号, 下面我们来区分一下 epoll使用 边缘触发通知 与 水平触发通知 在同样场景下的 区别:
- 套接字上有输入到来
- 我们调用一次epoll_wait, 无论采用水平触发通知,还是边缘触发通知,该调用 都会告诉我们套接字上已经处于就绪态态
- 再次调用epoll_wait, 水平触发情况下: 套接字处于就绪态, 边缘触发情况下: 调用将阻塞,因为自从上一次之后并没有新的请求输入到来
- 边缘触发通知 如何避免出现文件描述符饥饿现象: 监视多个文件描述符,其中一个处于就绪态的文件描述符有这大流量的输入存在,然后我们通过非阻塞方式读取所有输入,那么此时就有是其他的文件描述符存在饥饿状态的风险。即:我们再次检查这些文件描述符是否处于就绪态并执行IO操作前,会有很长一段时间, 该问题的解法是:
- 应用程序维护一个处于就绪态的文件描述符列表,通过一个循环不断处理如下
- epoll_wait 监控文件描述符,将 就绪态的 文件描述符 添加到应用程序维护的列表中。如果
有文件描述符 已经存在于 应用程序维护的列表中,那么这次监视的超时时间设定的较小或者是0, 这样应用程序就可以快速的进行到下一步,去处理哪些已经处于就绪态的文件描述 - 在应用程序维护的列表中,轮转调度 进行一定限度的 IO操作。(不是从 上一步 epoll_wait调用中返回的 文件描述列表 遍历处理)当相关的非阻塞IO调用出现EAGAIN 或者 EWOULDBLOCK错误,从应用程序维护的列表中移除
- 在信号和文件描述符上等待 ? (不知道为什么会出现有关竞争的问题?)以及出现pselect 调用, self-pipe技巧
- 使用信号驱动的程序 应该按照如下方式 来编程:
- int select(int nfds, fd_set * readfds, fd_set * writefds, fd_set * exceptfds, struct timeval * timeout):
问题列表
- DNS 负载 如何实现?
- 信号 相关的设定 是如何的? 为什么会出现 如此复杂的设计,race conditoin,
- epoll 中 一小段不能交换的内存是?新概念吗?
- epoll 的内部实现
- 消息边界的概念,以及实际意义