Windows下网络编程
Winsock
Windows下的网络编程基于Winsock(Windows Sockets API),是对BSD socket的封装扩展,核心头文件是winsock2.h,需要链接ws2_32.lib
流程
- 基本流程和
Linux socket类似 - 区别在于
Windows提供了更丰富的I/O模型来解决高并发问题
|
1 2 3 4 5 6 7 |
WSAStartup() → socket() → bind() → listen() → accept() → send()/recv() → closesocket() |
socket到底创建了什么
- 调用
socket(AF_INET, SOCK_STREAM, IPPROTO_TCP)时,操作系统内核做的事情是:- 在内核里分配一个
socket结构体(Linux下是struct socket+struct sock,Windows下是内核对象+TCP_ENDPOINT),这个结构体里包含: - 本地地址/端口(此时还是空的,未绑定)
- 对端地址/端口(此时也是空的)
TCP传输控制块(TCB):状态机(CLOSED状态)、序号计数器、窗口大小等- 接收缓冲区、发送缓冲区(内核态的一块内存,此时是空的环形缓冲区)
- 在内核里分配一个
- 内核把这个
socket对象和一个文件描述符(Linux下是int,Windows下是SOCKET句柄)关联起来,返回给用户程序 - 关键点:
socket()这一步只是"申请了一个空白的通信端点",此时没有发生任何网络行为,纯粹是内核里分配了一块数据结构- 这也是为什么"
socket是内核对象"这个说法的来源——它本质上和文件、管道一样,是内核维护的一种资源,只是恰好这种资源的读写行为对应着网络收发
bind()做了什么
- 表面行为
- 调用后,内核把
addr里的IP地址+端口号,写进了这个socket内核对象的本地地址字段里 - 这一步之后,这个
socket就有了"本地身份":其他机器要发数据给你,得知道往这个IP+port发
- 调用后,内核把
|
1 |
int bind(SOCKET s, const struct sockaddr* addr, int addrlen); |
服务端为什么必须显式bind
- 服务端要监听一个固定、公开的端口(比如
80、443、9000),这样客户端才能提前知道"连这个端口就能找到你" - 如果不显式
bind(),操作系统会在你listen()时自动分配一个随机端口——但客户端没法提前知道这个随机端口是多少,也就没法连接你,所以服务端必须主动指定一个固定端口
客户端为什么通常不需要显式bind
- 客户端只需要主动发起连接,不需要让别人"提前找到自己",所以客户端通常直接调用
connect(),不用先bind() - 但这不代表客户端的
socket没有本地端口- 内核在
connect()内部,如果发现这个socket还没bind过,会自动帮你从临时端口范围(ephemeral port range)里挑一个空闲端口,隐式完成一次"隐式bind"
- 内核在
bind的几个具体底层动作
- 端口占用检查
- 内核会检查你要绑定的
(IP, 端口, 协议)组合有没有被其他socket占用 - 如果冲突,
bind()会返回错误——Windows下是WSAEADDRINUSE(Linux下是EADDRINUSE) - 这个检查本质上是查内核维护的一张"已绑定地址"哈希表/列表
- 内核会检查你要绑定的
INADDR_ANY的特殊处理- 这不是让内核"随便选一个
IP",而是告诉内核:"这个socket在所有本机网卡的IP上都能接收数据" - 比如一台服务器有内网
IP192.168.1.10和公网IP1.2.3.4两块网卡,绑定INADDR_ANY意味着不管客户端是从内网还是公网连进来(只要端口对得上),这个socket都能收到 - 如果显式绑定成
192.168.1.10,那么只有从这个网卡收到的、目标端口匹配的包,才会被投递给这个socket
- 这不是让内核"随便选一个
|
1 |
addr.sin_addr.s_addr = INADDR_ANY; // 即0.0.0.0 |
- 端口号为
0的特殊处理- 如果你写
sin_port = htons(0),内核会自动帮你从临时端口范围里挑一个可用端口(等同于不bind直接connect时内核做的事) - 这也是为什么有些服务端会写
bind()时端口填0,让操作系统去分配一个随机可用端口,常用于一些不关心固定端口号的场景
- 如果你写
SO_REUSEADDR- 一个绕不开的常见坑
- 服务端程序重启时,经常会遇到
bind()报错Address already in use,明明看起来端口没人用 - 这是因为
TCP连接关闭后,操作系统会让这个"四元组"停留在TIME_WAIT状态一段时间(通常是2倍MSL,2-4分钟),防止网络上滞留的旧数据包干扰新连接 - 段时间内,默认情况下你没法重新
bind()同一个端口 - 解决方法是在
bind()之前设置: - 这个选项告诉内核:"即使这个端口有旧连接处于
TIME_WAIT,也允许我重新绑定"
这是几乎所有生产环境服务端程序的标配设置,不加的话你会发现每次重启服务都得等几分钟才能重新监听同一个端口,开发调试时会很痛苦
|
1 2 |
BOOL reuse = TRUE; setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, (char*)&reuse, sizeoreuse)); |
listen()做了什么
- 表面行为(调用后,内核做两件事)
- 把这个socket的状态从
CLOSED改成LISTEN - 在内核里为这个
socket分配并初始化两个队列——这才是listen()真正的核心工作
- 把这个socket的状态从
|
1 |
int listen(SOCKET s, int backlog); |
listen两个队列的机制
listen的backlog参数
- 这是一个经常被误解的地方:
backlog参数不是"最大能同时接受多少个客户端连接",而是Accept队列(全连接队列)的最大长度- 也就是"已经完成三次握手、但你的程序还没来得及调用
accept()取走"的连接最多能堆积多少个
|
1 |
listen(listenSocket, SOMAXCONN); // 或者填一个具体数字,比如128 |
- 为什么会有连接堆积在这里
- 如果你的服务端程序处理
accept()的速度跟不上新连接到达的速度(比如CPU繁忙、或者单线程在处理别的事),已经握手成功的连接就会在Accept队列里排队等着,队列满了之后,新到达的握手完成的连接会被内核丢弃 Windows下客户端会收到连接被拒绝或超时Linux下默认行为是丢弃对应的ACK,让客户端触发重传
- 如果你的服务端程序处理
SOMAXCONN是系统定义的一个"建议最大值"Windows下这个值由系统限制且会被内部截断到一个上限(不同Windows版本上限不同)- 实际生产环境很多人会填一个具体数字比如
128、512,根据预期并发连接速率调整
listen为什么要分两个队列而不是一个
SYN Flood攻击防护- 如果只有一个队列
- 攻击者可以发送大量伪造源
IP的SYN包(这叫SYN Flood攻击),服务端对每个SYN都要回复SYN+ACK并且分配资源等待 - 因为攻击者根本不会回复最后的
ACK,这些"半连接"会一直占着队列位置直到超时,很容易把队列打满,导致正常用户的连接请求被拒绝
- 攻击者可以发送大量伪造源
- 分成两个队列后
SYN队列(半连接)和Accept队列(全连接)是分开管理的,操作系统可以针对"半连接队列"单独做优化,比如:SYN Cookies机制:SYN队列快满时,内核不再真正维护半连接状态,而是把连接信息编码进SYN+ACK的序号里发回去,等对方真的回复ACK时再从这个序号里解码还原出连接信息
这样即使遭遇SYN Flood,也不需要真正为每个半连接分配内存,从而防御攻击(这是Linuxtcp_syncookies选项的原理Windows下也有类似的SYN攻击保护机制,叫SYN attack protection,在注册表TcpMaxHalfOpen等参数里可调
listen()和端口状态的关系
- 当你执行
netstat -ano看到某个端口状态是LISTENING,对应的正是这个socket调用了listen()之后的状态 - 这个状态意味着:
- 这个
socket永远不会自己发起收发数据,它唯一的工作是"接受新连接请求,然后把完成握手的连接扔进Accept队列" - 真正收发业务数据的,是
accept()返回的那些新socket
- 这个
accept()做了什么
- 函数签名和表面行为
s:监听socket(已经listen()过的那个)addr:出参,内核会把发起连接的客户端地址信息填进来addrlen:输入输出参数,传入你缓冲区大小,返回实际填充的大小- 返回值是一个全新的
socket句柄
|
1 |
SOCKET accept(SOCKET s, struct sockaddr* addr, int* addrlen); |
accept底层
- 一:从
Accept队列头部取出一个连接listen()维护了一个Accept队列,里面存的是已经完成三次握手的连接accept()本质上就是从这个队列头部摘取一个节点- 内核里这个队列通常实现为一个链表,摘取操作本身是
O(1)的
- 如果队列是空的:
- 阻塞模式下,
accept()会阻塞等待,直到有新连接完成握手进入队列 - 非阻塞模式下,
accept()立刻返回错误:Windows下是WSAEWOULDBLOCK,Linux下是EWOULDBLOCK/EAGAIN
- 阻塞模式下,
- 二:创建一个全新的
socket内核对象- 内核为这个已经握手成功的连接新建一个
socket结构体(前面讲的struct sock/inet_sock/tcp_sock这一整套嵌套结构),并把这个新socket和一个新的文件描述符/句柄关联起来返回给你
- 内核为这个已经握手成功的连接新建一个
- 这个新
socket对象里已经提前填好了:- 本地地址、本地端口(继承自监听
socket的绑定地址) - 对端地址、对端端口(就是发起连接的客户端信息)
TCP状态:ESTABLISHED(因为握手已经在进入Accept队列之前就完成了)- 序号信息:
snd_nxt、rcv_nxt等,握手过程中协商好的 - 换句话说
accept()返回的这个新socket,从一"出生"就已经是一个五元组完整、状态是ESTABLISHED的成熟连接,不需要你再额外调用connect()之类的操作
- 本地地址、本地端口(继承自监听
- 三:填充客户端地址信息到你传入的
addr参数- 内核把这个连接对应的客户端IP和端口,从内核的连接记录里拷贝到你传入的
sockaddr结构体里 - 这样应用层代码就能知道"这个新
socket对应的是哪个客户端"
(常见用法是拿这个信息打日志、做IP白名单/黑名单判断等)
- 内核把这个连接对应的客户端IP和端口,从内核的连接记录里拷贝到你传入的
- 四:把这个连接从
Accept队列里移除- 摘取完成后,这个连接节点从
Accept队列的链表里断开,不会被下一次accept()重复取到
- 摘取完成后,这个连接节点从
accept()返回的是一个新的socket对象
accept()返回的是一个全新的socket对象,不是原来那个监听socket- 监听
socket自始至终只负责"排队等待新连接" - 真正用来和某个具体客户端收发数据的,是
accept()每次返回的新socket
- 监听
- 这也是为什么高并发服务器里,一个监听
socket可以同时对应成千上万个已连接的客户端socket,它们是各自独立的内核对象,只是共享同一个"入口" accept()返回新socket之后- 内核会把这个连接的五元组注册进之前讲的全局连接哈希表(
ehash)里 - 这样当后续网卡收到属于这个连接的数据包时,内核能在
O(1)时间内,通过五元组直接定位到这个新socket,把数据放进它的接收缓冲区,而不是监听socket的缓冲区
- 内核会把这个连接的五元组注册进之前讲的全局连接哈希表(
connect为什么能建立连接
sockaddr_in的作用sockaddr_in本身只是一个数据容器
|
1 2 3 4 5 |
struct sockaddr_in { sin_family; // AF_INET sin_port; // 目标端口(网络字节序) sin_addr; // 目标IP地址(网络字节序) }; |
connect()底层发生的事情,按顺序:- 路由查找
内核根据sockaddr_in里的目标IP,查询本机的路由表,决定这个包该从哪个网卡发出去、下一跳网关是谁 - 分配本地端口和源
IP
如果之前没有bind()过,内核会自动从临时端口范围(ephemeral port,一般是1024-65535之间的一段)里挑一个空闲端口作为源端口,同时确定用哪个网卡的IP作为源地址 ARP解析(如果需要)
如果目标是同一局域网内的机器,内核需要知道目标IP对应的MAC地址才能把包发出去,这时候会发ARP广播询问"谁是这个IP",得到MAC地址后缓存起来
如果目标不在同一网段,则用网关的MAC地址TCP三次握手(这才是"连接建立"的核心)
客户端发送一个SYN包(带随机初始序号x),此时客户端TCP状态变成SYN_SENT
服务端收到SYN,回复SYN+ACK(带自己的初始序号y,并确认x+1),服务端状态变成SYN_RCVD
客户端收到后回复ACK(y+1),双方状态都变成ESTABLISHED
- 路由查找
|
1 2 3 4 5 |
客户端 服务端 |------ SYN(seq=x) --------------->| 客户端: CLOSED → SYN_SENT |<----- SYN(seq=y), ACK(x+1) ------| 服务端: LISTEN → SYN_RCVD |------ ACK(y+1) ------------------>| 客户端: SYN_SENT → ESTABLISHED 服务端: SYN_RCVD → ESTABLISHED |
connect()这个系统调用会阻塞(除非是非阻塞socket),直到这个三次握手完成(或超时失败),才返回给应用层
connect本质原理
sockaddr_in提供了"要连去哪"的信息connect()触发内核完成了"路由决策 + 地址解析 +TCP握手"这一整套动作- 握手成功后,内核把这个
socket对象的状态从CLOSED改成ESTABLISHED,并且把对端地址、序号信息填进了socket结构体里 - 这样这个
socket对象就从"空白的端点"变成了"绑定了具体一条连接的端点"
connect对端信息存放
Linux下,一个TCP socket在内核里其实是层层嵌套的几个结构体,不是一个平铺的大结构:
Linux内核用了一个很经典的C语言技巧struct tcp_sock的第一个成员就是struct inet_connection_sock,它的第一个成员又是struct inet_sock,再往上第一个成员是struct sock- 因为第一个成员的内存地址和外层结构体的起始地址完全相同,所以内核可以直接把一个
struct tcp_sock*指针强转成struct sock*来用
反之则用container_of宏根据偏移量换算回去
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
struct tcp_sock { struct inet_connection_sock inet_conn; // 必须是第一个成员 // ... TCP特有字段:snd_nxt, rcv_nxt, snd_wnd 等 }; struct inet_connection_sock { struct inet_sock icsk_inet; // 必须是第一个成员 // ... 拥塞控制相关字段 }; struct inet_sock { struct sock sk; // 必须是第一个成员 // ... inet_daddr, inet_dport 等 }; |
- 关于对端信息存放:
- 对端地址(
IP+端口) → 填进了inet_sock里的inet_daddr(目的IP)和inet_dport(目的端口) - 序号信息 → 填进了
tcp_sock里的rcv_nxt(下一个期望收到的序号,也就是要回复的ACK号)和snd_nxt(下一个要发送的序号) - 连接状态从
CLOSED变成ESTABLISHED→ 改的是最外层struct sock里的sk_state字段
- 对端地址(
Windows下的对应关系(TCP/IP协议栈是tcpip.sys,源码不公开)- 虽然微软没公开源码,但从公开的
WinDbg调试符号和文档能看到类似的分层设计 - 对端地址存在
TCP Endpoint对象里 - 序号信息存在
TCB(Transmission Control Block)结构里 - 状态机同样是一个类似
sk_state的枚举字段
- 虽然微软没公开源码,但从公开的
socket全局连接哈希表
- 为什么
recv()能立刻找到"该给哪个socket- 内核维护一个全局的连接哈希表(
ehash,established hash table) - 每次网卡收到一个
TCP包,内核会用这个包的五元组去查这张哈希表,直接定位到对应的struct sock指针,然后把数据挂到它的sk_receive_queue上 - 这一步查找是
O(1)级别的,不是遍历所有连接去找,这也是Linux网络栈能支撑高并发连接的关键设计之一
- 内核维护一个全局的连接哈希表(
send()、recv()原理
- 关键前提:连接建立后,"身份"已经确定
- 三次握手完成后,这条连接在内核里由一个五元组唯一标识:
- 这个五元组被记录在
socket对象的TCB里 - 之后每一次
send/recv,内核都不需要你再告诉它"发给谁"——因为socket对象本身已经"记住"了对端是谁 - 这也是
send()函数不需要再传地址参数(而sendto()需要,因为UDP是无连接的)的原因
|
1 2 3 |
(协议, 源IP, 源端口, 目的IP, 目的端口) (TCP, 192.168.1.10, 54321, 192.168.1.20, 9000) |
send() 做了什么
- 调用
send(sock, buf, len, 0)时: - 第一步:数据从用户态拷贝到内核态
- 你在应用层准备的
buf是用户空间的内存,内核会把这段数据拷贝到这个socket对应的内核发送缓冲区(一块环形缓冲区,大小可以通过SO_SNDBUF调整)
- 你在应用层准备的
- 第二步:
TCP层封装TCP协议栈从发送缓冲区取出数据,按照MSS(最大报文段长度,一般1460字节左右)切分成一个或多个TCP段,每个段加上TCP头(包含序号、确认号、窗口大小、校验和等)- 这个过程叫封装
- 第三步:
IP层封装TCP段交给IP层,加上IP头(源IP、目的IP、TTL等),形成IP数据包
- 第四步:链路层封装并通过网卡发出
IP包交给网卡驱动,加上以太网帧头(源MAC、目的MAC),通过网卡的DMA机制把数据放到网卡的发送队列,网卡把电信号/光信号发到物理链路上
send()调用成功返回
- 并不代表数据已经发到对方那里,只代表"数据已经从你的用户缓冲区拷贝进了内核发送缓冲区"
- 真正把数据发出去,是
TCP协议栈根据滑动窗口和拥塞控制算法自己决定的时机
recv() 做了什么
- 数据到达服务端网卡后,是一个反向的层层解封装过程
- 网卡收到数据
- 触发中断,把数据从网卡缓冲区DMA拷贝到内核的一块网络缓冲区(
sk_buff结构,Linux下)
- 触发中断,把数据从网卡缓冲区DMA拷贝到内核的一块网络缓冲区(
- 链路层剥掉以太网帧头
IP层剥掉IP头,做一些校验(比如校验和、TTL),根据IP头判断这是TCP包,往上交给TCP层TCP层剥掉TCP头,根据五元组找到对应的socket对象- 这一步很关键——内核维护了一张连接哈希表,用五元组作为
key,能快速定位到是哪个进程的哪个socket该接收这个数据
- 这一步很关键——内核维护了一张连接哈希表,用五元组作为
- TCP层做序号校验
- 是不是期望的下一个序号?是否需要重排序?是否需要发
ACK确认? - 确认无误后,把数据放进这个
socket的内核接收缓冲区
- 是不是期望的下一个序号?是否需要重排序?是否需要发
- 如果这时候应用层正阻塞在
recv()调用上(在等待数据),内核会唤醒这个等待的线程 recv()函数把数据从内核接收缓冲区拷贝到你提供的用户态buf里,返回实际拷贝的字节数
recv() 本质原理
- 不是"主动去网络上取数据",而是"从内核已经放好的接收缓冲区里,把数据搬到用户空间"
- 真正从网线上收数据、做
TCP协议处理,是网卡中断 + 内核协议栈在你调用recv()之前就已经异步完成的 - 这也是为什么会有阻塞/非阻塞、以及
IOCP这类异步IO模型的意义- 也就是说,
recv()本质上只是"读一块已经准备好的内核内存",如果这块内存里没数据: - 要么阻塞等待
- 要么(非阻塞模式)立刻返回错误
- 要么(
IOCP)通过完成端口异步通知你"数据到了,可以来读了"
- 也就是说,
socket开发本质
概述
socket API是唯一的底座,所有网络程序都要用它,你没法绕开socket()、bind()、listen()、send()、recv()这套接口去写网络程序- 不管你是写最简单的单线程阻塞式程序,还是用
epoll/IOCP写高并发服务器,最终都要调用这些函数
socket的状态
- 真正有"选择"的,是你怎么去管理这些
socket的"读写就绪状态"- 这才是
IO模型要解决的问题
- 这才是
可读、可写、异常这三个状态- 是
select、WSAEventSelect、epoll这些"就绪通知模型"的核心判断依据 - 所有这些模型本质上都是在替你反复问内核这三个问题
- 是
socket可读
- 触发条件1:接收缓冲区里有数据
- 最直观的情况:对端发了数据过来,内核把数据放进了这个
socket的接收缓冲区,只要缓冲区不为空,就判定为"可读" - 这时候调用
recv()能读到数据,返回值大于0
- 最直观的情况:对端发了数据过来,内核把数据放进了这个
- 触发条件2:对端正常关闭连接(收到
FIN)——这一条最容易被忽略- 当对端调用
close()/shutdown(),会给你发一个TCP的FIN包 - 这个事件也会被判定为"可读"——但你去
recv()的时候,会读到返回值为0,而不是读到数据
- 当对端调用
|
1 2 3 4 5 6 7 8 9 |
int n = recv(sock, buf, sizeof(buf), 0); if (n > 0) { // 正常收到数据 } else if (n == 0) { // 对端正常关闭连接,不是错误!这也是"可读"事件触发的原因之一 closesocket(sock); } else { // n == SOCKET_ERROR,真正的错误 } |
- 触发条件3:监听
socket上有新连接完成握手,等待accept- 监听
socket本身没有"数据"可言,但它一样会触发可读事件 - 原因是
accept()这个动作,本质上和recv()一样,都是"从某个队列里取东西、不阻塞地拿到结果",所以Berkeley socket的设计里,把"监听socket上有新连接可以accept"也归为"可读"这个大类 - 这也是为什么
select模型里,你需要把监听socket也加进readfds集合,而不是单独搞一个"可accept"的集合
- 监听
- 触发条件4:
socket发生错误- 如果这个连接因为网络问题(比如对端强制重置
RST、网络中断超时)出现了错误,也会触发可读事件,你去recv()会得到一个错误码
- 如果这个连接因为网络问题(比如对端强制重置
为什么"连接关闭"也算可读
- "可读"这个状态本质是在问"我现在调用
recv()会不会立刻返回(而不是卡住阻塞)" - 不管是"有数据"还是"连接已关闭",这两种情况下
recv()都会立刻返回而不阻塞,所以都归类到"可读"事件里
socket可写
- 触发条件1:发送缓冲区有可用空间
- 发送缓冲区没满,你调用
send()能把数据写进去(不会因为缓冲区满了而阻塞)
- 发送缓冲区没满,你调用
- 触发条件2:
socket刚创建时,默认就是可写的- 一个刚建立好连接的
socket,因为发送缓冲区是空的,从一开始就处于"可写"状态 - 这意味着如果你用
select/epoll监视writefds/EPOLLOUT,socket建立的第一时刻这个事件几乎立刻会触发 - 大多数生产代码的做法是默认不监视
EPOLLOUT/FD_WRITE,只有当某次send()因为缓冲区满了返回EWOULDBLOCK/WSAEWOULDBLOCK时,才临时注册可写事件,等缓冲区腾出空间后再继续发送剩余数据,发送完成后再取消监视,避免不必要的重复触发
- 一个刚建立好连接的
- 触发条件3:非阻塞
connect的完成通知- 这是可写事件一个非常经典的用法
- 如果你用非阻塞模式发起
connect(),函数会立刻返回(不等三次握手完成),真正握手完成(不管成功还是失败)的通知,是通过可写事件来告诉你的: - 注意这里必须用
getsockopt(SO_ERROR)来判断连接到底是成功还是失败,因为"可写事件触发"本身只代表"connect这个动作完成了",并不代表握手一定成功——握手失败(比如对端拒绝、超时)也会触发可写事件,只是SO_ERROR会带上具体的错误码
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
// 伪代码示意 connect(sock, ...); // 非阻塞,立刻返回 // 用select/epoll监视这个sock的可写事件 // 当可写事件触发时: int error = 0; socklen_t len = sizeof(error); getsockopt(sock, SOL_SOCKET, SO_ERROR, (char*)&error, &len); if (error == 0) { // 连接成功建立 } else { // 连接失败,error里是具体的错误码 } |
- 触发条件4:
socket发生错误- 和可读一样,
socket出错时可写事件也可能被触发,同样需要检查SO_ERROR来确认具体错误原因
- 和可读一样,
socket异常
- 用得最少、也最容易被误解的一类
- 唯一的核心触发条件:
TCP带外数据(OOB, Out-Of-Band data)到达
带外数据(OOB, Out-Of-Band data)
TCP协议里有一种叫"紧急指针(urgent pointer)"的机制,允许发送方标记一小段数据为"紧急数据",这段数据可以插队,让接收方即使还有正常数据排在前面没读,也能优先感知到这个紧急标记的存在- 这就是所谓的"带外数据"
- 默认情况下(没有设置
SO_OOBINLINE),带外数据不会和普通数据混在接收缓冲区里,而是通过"异常"这个独立的事件通道来通知你- 应用层需要用
recv(sock, buf, len, MSG_OOB)这个特殊标志来专门读取这段紧急数据
- 应用层需要用
- 这个机制在现代网络编程里几乎不再使用,原因是:
- 带外数据在实际的
TCP实现里语义比较模糊、各操作系统实现细节有差异,历史上出现过一些兼容性问题 - 现代应用层协议基本都是自己在应用层设计"优先级"或者"控制消息"机制(比如在协议头里加一个消息类型字段来区分普通数据和控制指令),而不依赖
TCP协议本身的这个古老特性
- 带外数据在实际的
- 所以你会发现,
select/epoll的exceptfds/EPOLLPRI这类"异常事件"监视,在实际生产代码里出现的频率极低
三种状态在几个模型里的具体映射
Windows的WSAEventSelect把"可读"这个大类拆得更细
| 状态 | select |
epoll(Linux) |
WSAEventSelect(Windows) |
| 可读 | readfds |
EPOLLIN |
FD_READ / FD_ACCEPT / FD_CLOSE |
| 可写 | writefds |
EPOLLOUT |
FD_WRITE / FD_CONNECT |
| 异常 | exceptfds |
EPOLLPRI |
FD_OOB |
Windows下的几种I/O模型
概述
- 按性能和复杂度从低到高
阻塞式(Blocking)
- 最简单,一个连接占一个线程,无法扩展
select模型
- 跨平台,但有
fd数量限制(FD_SETSIZE默认64)
WSAAsyncSelect
- 基于消息机制,适合
GUI程序,性能一般
WSAEventSelect
- 基于事件对象,比
select好但仍受WSA_MAXIMUM_WAIT_EVENTS(64)限制
重叠I/O(Overlapped I/O)
- 真正的异步
I/O,是IOCP的基础
IOCP(I/O Completion Port)
Windows平台性能最强的高并发网络模型
select模型
解决的问题
- 如果用纯阻塞式
socket,一个线程调用recv()就会卡死等待,没法同时处理多个连接(除非一个连接开一个线程,但线程数一多,上下文切换开销就很大) select模型的核心思路:- 让一个线程可以同时"盯着"一批
socket,问操作系统"这些socket里,哪些现在可读、哪些可写、哪些出异常了?" - 等内核告诉你答案后,你再针对"已经准备好"的那些
socket调用recv/send,这样就不会白白卡在没数据的socket上
- 让一个线程可以同时"盯着"一批
核心数据结构
fd_set本质就是一个socket句柄的集合Windows下的实现是数组,不是Linux下的位图,这点和Linux不太一样
|
1 2 3 4 |
typedef struct fd_set { u_int fd_count; // 当前集合里的socket数量 SOCKET fd_array[FD_SETSIZE]; // socket句柄数组,FD_SETSIZE默认是64 } fd_set; |
- 围绕它有几个操作宏
|
1 2 3 4 |
FD_ZERO(&set); // 清空集合 FD_SET(sock, &set); // 把sock加入集合 FD_CLR(sock, &set); // 把sock从集合移除 FD_ISSET(sock, &set); // 判断sock是否在集合里(也用来判断是否就绪) |
select函数签名和语义
- 关键点
select是阻塞调用(除非超时设为0),它会一直等到readfds/writefds/exceptfds里至少有一个socket就绪,或者超时,才返回- 返回后,这几个
fd_set会被原地修改
内核把不再就绪的socket从集合里剔除,只留下真正就绪的那些,所以调用完之后你需要重新遍历,用FD_ISSET挨个检查谁还在集合里
|
1 2 3 4 5 6 7 |
int select( int nfds, // Windows下这个参数被忽略,纯粹是为了兼容BSD socket API fd_set* readfds, // 关心"可读"事件的socket集合 fd_set* writefds, // 关心"可写"事件的socket集合 fd_set* exceptfds, // 关心"异常"事件的socket集合(比如带外数据) const timeval* timeout // 超时时间,NULL表示一直阻塞到有事件发生 ); |
code
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 |
#include <winsock2.h> #include <vector> #include <iostream> #pragma comment(lib, "Ws2_32.lib") int main() { WSADATA wsaData; WSAStartup(MAKEWORD(2,2), &wsaData); SOCKET listenSocket = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); sockaddr_in addr{}; addr.sin_family = AF_INET; addr.sin_addr.s_addr = INADDR_ANY; addr.sin_port = htons(9000); bind(listenSocket, (sockaddr*)&addr, sizeof(addr)); listen(listenSocket, SOMAXCONN); std::vector<SOCKET> clients; // 维护所有客户端连接 while (true) { fd_set readSet; FD_ZERO(&readSet); FD_SET(listenSocket, &readSet); // 监听socket也要放进去,才能感知新连接 for (SOCKET s : clients) { FD_SET(s, &readSet); } // 阻塞等待,直到有socket可读(新连接到达 或 已有连接有数据) int ret = select(0, &readSet, nullptr, nullptr, nullptr); if (ret == SOCKET_ERROR) break; // 检查监听socket:有没有新连接 if (FD_ISSET(listenSocket, &readSet)) { SOCKET newClient = accept(listenSocket, nullptr, nullptr); clients.push_back(newClient); std::cout << "新连接: " << newClient << std::endl; } // 遍历所有客户端,检查谁有数据可读 for (size_t i = 0; i < clients.size(); ) { SOCKET s = clients[i]; if (FD_ISSET(s, &readSet)) { char buf[1024]; int n = recv(s, buf, sizeof(buf), 0); if (n <= 0) { // 连接断开,从列表移除 closesocket(s); clients.erase(clients.begin() + i); continue; } send(s, buf, n, 0); // echo回显 } ++i; } } return 0; } |
select模型的致命缺陷
- 集合大小限制(
FD_SETSIZE)Windows下FD_SETSIZE默认是64,也就是一次最多同时监视64个socket- 虽然可以在包含
winsock2.h之前#define FD_SETSIZE 1024来放大,但这需要重新编译Winsock相关代码,且过大之后性能会明显下降
- 每次调用都要在用户态和内核态之间来回拷贝整个集合
- 每次
select()调用,都要把完整的fd_set从用户空间拷贝到内核空间,内核检查完再把结果拷贝回来 socket数量越多,这个拷贝开销越大——这是O(n)级别的开销,n是你监视的socket总数
- 每次
- 内核需要线性扫描整个集合
- 内核收到
fd_set后,要逐个遍历里面每一个socket,检查它的状态是否就绪——这也是O(n)的 - 如果你有
1000个连接,其中只有1个有数据,select依然要把这1000个socket全部检查一遍才能找出那一个
- 内核收到
- 每次调用前都要重新构建
fd_set- 因为
select返回后会修改传入的集合(把没就绪的socket踢出去),所以下一次调用前你必须重新调用FD_ZERO+FD_SET把所有关心的socket重新加一遍
- 因为
和其他模型的对比
| 模型 | 一次调用的复杂度 | 最大连接限制 | 是否跨平台 |
select |
O(n) 拷贝+扫描 |
64(Windows),1024(Linux,可调) |
是,BSD socket标准,Windows/Linux都支持 |
WSAEventSelect |
O(n) 扫描,但用事件对象 |
64(WSA_MAXIMUM_WAIT_EVENTS限制) |
否,Windows专有 |
epoll(Linux) |
O(1),只返回就绪的 |
理论无上限 | 否,Linux专有 |
IOCP(Windows) |
O(1),完成通知模型 |
理论无上限 | 否,Windows专有 |
select模型现在还有什么实际价值
- 说实话,在生产环境的高性能服务端里,
select模型基本已经被淘汰 - 了解它的价值主要在于:
- 理解
IO复用的最原始设计思路,再学epoll/IOCP这些优化方案时更容易明白"它们到底优化了select的哪些短板" - 跨平台兼容性场景:因为
select是BSD socket标准的一部分,Windows和Linux都支持,一些对性能要求不高、但要求"代码一套跑两个平台"的小工具,可能还会用select图个简单
- 理解
wsaasyncselect
概述
WSAAsyncSelect是Windows特有的一种IO模型,专门为GUI应用程序设计的
WSAAsyncSelect解决的问题
select模型有个明显问题:- 它是阻塞调用(或者你得不断轮询),如果你在一个有界面的
Windows程序(比如MFC/Win32窗口程序)里直接调用select(),主线程会被卡住,导致界面卡死、无响应 - 因为
Windows的GUI程序依赖一个"消息循环"来处理鼠标点击、窗口重绘等事件,你不能让这个循环被网络IO卡住
- 它是阻塞调用(或者你得不断轮询),如果你在一个有界面的
WSAAsyncSelect的思路是:- 把
socket事件转换成Windows消息,塞进你程序原本就有的消息循环里去处理,这样完全不需要额外阻塞或者轮询,网络事件和界面事件用同一套消息机制统一处理
- 把
函数签名和使用方式
|
1 2 3 4 5 6 |
int WSAAsyncSelect( SOCKET s, // 要监视的socket HWND hWnd, // 接收消息的窗口句柄 unsigned int wMsg, // 自定义的消息编号(通常是 WM_USER + 某个数字) long lEvent // 关心哪些网络事件(可以按位或组合) ); |
lEvent可以是这些事件的组合
|
1 2 3 4 5 |
FD_READ // socket上有数据可读了 FD_WRITE // socket可以写数据了(发送缓冲区有空闲) FD_ACCEPT // 监听socket上有新连接到达 FD_CONNECT // connect操作完成(用于非阻塞connect) FD_CLOSE // 连接关闭 |
底层原理——事件如何变成窗口消息
- 第一步:注册阶段
- 调用
WSAAsyncSelect时,Winsock内部做了两件事: - 把这个
socket自动设置成非阻塞模式(这是隐式发生的,不需要你额外调ioctlsocket) - 把
(socket, 窗口句柄, 消息编号, 关心的事件)这组关系记录在Winsock内部维护的一张表里
- 调用
- 第二步:内核检测到网络事件发生
- 比如网卡收到了数据,
TCP协议栈处理完之后,发现这个socket正被WSAAsyncSelect监视,并且这次事件(比如"有数据可读")正好是注册时关心的FD_READ类型
- 比如网卡收到了数据,
- 第三步:
Winsock通过PostMessage把事件封装成一条Windows消息,塞进你窗口的消息队列
|
1 |
PostMessage(hWnd, wMsg, (WPARAM)socket, WSAMAKESELECTREPLY(event, error)); |
- 第四步:你的消息循环收到这条消息,正常处理
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
LRESULT CALLBACK WndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam) { switch (message) { case WM_SOCKET_MSG: // 你自定义的消息编号 { SOCKET sock = (SOCKET)wParam; int event = WSAGETSELECTEVENT(lParam); // 解析出具体是哪种事件 int error = WSAGETSELECTERROR(lParam); // 解析出是否有错误 if (error != 0) { // 处理错误 break; } switch (event) { case FD_READ: { char buf[1024]; int n = recv(sock, buf, sizeof(buf), 0); // 处理收到的数据... } break; case FD_ACCEPT: { SOCKET client = accept(sock, nullptr, nullptr); // 对新连接调用WSAAsyncSelect,继续监视它的FD_READ/FD_CLOSE WSAAsyncSelect(client, hWnd, WM_SOCKET_MSG, FD_READ | FD_CLOSE); } break; case FD_CLOSE: closesocket(sock); break; } } break; } return DefWindowProc(hWnd, message, wParam, lParam); } |
和其他模型的对比与局限
- 相比阻塞式/
select- 不会卡住主线程,天然适合和
GUI消息循环整合
- 不会卡住主线程,天然适合和
- 相比
IOCP,WSAAsyncSelect有明显的性能短板:- 消息队列的开销:每个网络事件都要走一遍
PostMessage → 消息队列 → GetMessage → DispatchMessage这套完整的Windows消息机制,比IOCP直接的完成通知开销大得多 - 单线程消息循环是瓶颈:所有
socket事件最终都堆到同一个窗口的消息队列里,由一个线程串行处理,没法像IOCP那样用线程池并行处理多个完成事件,高并发下这是致命短板 FD_READ语义容易踩坑:FD_READ是"边缘触发"式的通知——只在数据到达那一刻通知一次,如果你收到通知后没有一次性把数据读完,剩下的数据不会重复触发新的FD_READ消息(除非有新数据到达)
和select的"水平触发"(只要还有数据没读完,每次调用都会告诉你可读)行为不同,是这个模型一个容易被忽略的坑点
- 消息队列的开销:每个网络事件都要走一遍
实际使用场景定位
WSAAsyncSelect基本上只适合连接数不多、且本身就是GUI程序的场景- 比如:
- 早期的
MFC聊天工具、下载工具客户端(连接数通常个位数到几十) - 一些需要在界面程序里做简单网络通信、但又不想开额外线程处理网络
IO的小工具
- 早期的
wsaeventselect
概述
WSAEventSelect是Windows网络编程演进链条里,继WSAAsyncSelect之后的下一步- 它解决了
WSAAsyncSelect"必须依赖窗口消息循环"这个限制,让非GUI程序也能用事件驱动的方式做网络IO,同时还没有IOCP那么复杂
WSAEventSelect解决的问题
WSAAsyncSelect有个硬性要求- 必须有一个窗口句柄和消息循环,这对纯后台服务程序、控制台程序不太友好
- 总不能为了收发网络数据,专门造一个隐藏窗口去跑消息循环(虽然技术上可以这么做,但显得别扭)
WSAEventSelect换了一种通知机制- 不用
Windows消息,而是用Windows的通用同步对象——事件对象(Event Object) 来通知网络事件,这样任何线程(不需要消息循环)都可以用标准的WaitForMultipleObjects来等待网络事件,和等待其他内核对象(互斥锁、信号量)用的是同一套机制
- 不用
核心API和数据结构
- 第一步:创建一个事件对象
|
1 |
WSAEVENT hEvent = WSACreateEvent(); // 本质就是CreateEvent()的封装 |
- 第二步:把
socket和这个事件对象、关心的事件类型绑定- 把
socket自动设成非阻塞模式(和WSAAsyncSelect一样是隐式的) - 告诉内核:"这个
socket上如果发生了lNetworkEvents里指定的任何一种事件,就把hEventObject这个事件对象设置成'有信号'状态"
- 把
|
1 2 3 4 5 |
int WSAEventSelect( SOCKET s, WSAEVENT hEventObject, long lNetworkEvents // FD_READ | FD_WRITE | FD_ACCEPT | FD_CLOSE 等 ); |
- 第三步:用
WSAWaitForMultipleEvents等待事件发生- 这个函数本质是
WaitForMultipleObjects的Winsock封装,会阻塞等待,直到lphEvents数组里任意一个事件对象被置为有信号状态(或超时) - 返回值告诉你是数组里第几个事件触发了
- 这个函数本质是
|
1 2 3 4 5 6 7 |
DWORD WSAWaitForMultipleEvents( DWORD cEvents, const WSAEVENT* lphEvents, BOOL fWaitAll, DWORD dwTimeout, BOOL fAlertable ); |
- 第四步:用
WSAEnumNetworkEvents查出具体是哪种事件、清除信号状态- 这一步很关键:事件对象只是一个"有没有发生过某种事件"的信号灯,具体发生的是
FD_READ还是FD_WRITE还是FD_CLOSE,需要靠WSAEnumNetworkEvents来查询 - 而且这个函数调用完之后,会自动把事件对象重置为无信号状态,为下一次事件做准备
- 这一步很关键:事件对象只是一个"有没有发生过某种事件"的信号灯,具体发生的是
|
1 2 3 4 5 6 7 8 9 |
WSANETWORKEVENTS networkEvents; WSAEnumNetworkEvents(socket, hEvent, &networkEvents); if (networkEvents.lNetworkEvents & FD_READ) { // 有数据可读 if (networkEvents.iErrorCode[FD_READ_BIT] == 0) { // 没有错误,可以调用recv } } |
code
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 |
#include <winsock2.h> #include <iostream> #pragma comment(lib, "Ws2_32.lib") int main() { WSADATA wsaData; WSAStartup(MAKEWORD(2,2), &wsaData); SOCKET listenSocket = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); sockaddr_in addr{}; addr.sin_family = AF_INET; addr.sin_addr.s_addr = INADDR_ANY; addr.sin_port = htons(9000); bind(listenSocket, (sockaddr*)&addr, sizeof(addr)); listen(listenSocket, SOMAXCONN); WSAEVENT hEvent = WSACreateEvent(); WSAEventSelect(listenSocket, hEvent, FD_ACCEPT); std::vector<SOCKET> clients; std::vector<WSAEVENT> events{hEvent}; std::vector<SOCKET> sockets{listenSocket}; while (true) { DWORD index = WSAWaitForMultipleEvents( events.size(), events.data(), FALSE, WSA_INFINITE, FALSE); int i = index - WSA_WAIT_EVENT_0; // 计算出是第几个事件触发 SOCKET triggeredSocket = sockets[i]; WSANETWORKEVENTS netEvents; WSAEnumNetworkEvents(triggeredSocket, events[i], &netEvents); if (netEvents.lNetworkEvents & FD_ACCEPT) { SOCKET client = accept(triggeredSocket, nullptr, nullptr); WSAEVENT clientEvent = WSACreateEvent(); WSAEventSelect(client, clientEvent, FD_READ | FD_CLOSE); sockets.push_back(client); events.push_back(clientEvent); std::cout << "新连接: " << client << std::endl; } if (netEvents.lNetworkEvents & FD_READ) { char buf[1024]; int n = recv(triggeredSocket, buf, sizeof(buf), 0); if (n > 0) send(triggeredSocket, buf, n, 0); // echo } if (netEvents.lNetworkEvents & FD_CLOSE) { closesocket(triggeredSocket); WSACloseEvent(events[i]); sockets.erase(sockets.begin() + i); events.erase(events.begin() + i); } } return 0; } |
致命限制:WSA_MAXIMUM_WAIT_EVENTS = 64
- 这是
WSAEventSelect模型最大的短板,直接决定了它没法用在真正高并发的场景WSAWaitForMultipleEvents(本质是WaitForMultipleObjects)一次最多只能等待64个事件对象- 这是
Windows内核本身的限制,不是Winsock特意设的 - 这意味着一个线程最多同时管理
64个连接
- 绕过方法
- 如果连接数超过
64,只能开多个线程,每个线程各自维护一组不超过64个的事件数组 - 但这样又回到了"一个连接分组占一个线程"的老问题,只是从"一连接一线程"变成了"
64连接一线程" - 相比
IOCP用固定线程池就能支撑成千上万连接,伸缩性差了一个数量级
- 如果连接数超过
和FD_READ的边缘触发陷阱
WSAEventSelect的FD_READ事件和WSAAsyncSelect一样,行为上接近边缘触发- 只在数据从无到有的那一刻触发一次,如果你这次没有把数据读完(比如缓冲区只读了一半),不会因为还有剩余数据就重复触发,除非又有新数据到达
现在还有什么实际价值
- 和
WSAAsyncSelect类似,WSAEventSelect在真正的生产级高并发服务端里基本不会被选用 - 它的价值主要在于
- 理解"就绪通知"和"完成通知"之间的一个中间形态
- 一些工具类小程序或者连接数明确不会超过
64的场景,比如内部管理工具、连接数可控的代理程序,图它比裸select好写、又不想直接上IOCP这种复杂度的场景
其他
环形缓冲区
- 概述
- "环形缓冲区"(
Ring Buffer / Circular Buffer)是一种非常经典的数据结构
- "环形缓冲区"(
- 核心思想
- 普通数组用完了就是用完了,要么报错要么申请更大内存重新拷贝
- 环形缓冲区把一块固定大小的连续内存,逻辑上首尾相连成一个"环",用两个指针(读指针、写指针)分别标记"读到哪了"和"写到哪了",写指针写到数组末尾后,会绕回数组开头继续写
- 只要读指针跟得上,这块固定内存就可以被反复循环利用,不需要重新分配内存
- 实现原理
- 底层通常就是一个普通数组 + 两个索引
- 当
writePos加到等于SIZE时,取模后自动变回0,逻辑上就相当于"绕回了数组开头",不需要真的做任何数据搬移
|
1 2 3 4 5 |
struct RingBuffer { char buffer[SIZE]; int readPos; // 下一个要读的位置 int writePos; // 下一个要写的位置 }; |
- 写入数据
% SIZE这个取模运算就是"环形"效果的核心
|
1 2 |
buffer[writePos] = data; writePos = (writePos + 1) % SIZE; // 关键:用取模运算实现"绕回" |
- 读取数据
|
1 2 |
data = buffer[readPos]; readPos = (readPos + 1) % SIZE; |
- 怎么判断缓冲区是空还是满
- 这是环形缓冲区实现里最容易踩坑的地方,因为
readPos == writePos时,既可能表示"空的",也可能表示"满的"(转了一整圈正好追上) - 常见解决方式有两种:
- 额外维护一个计数器(记录当前存了多少字节),不单纯依赖指针相对位置
- 故意浪费一个位置:约定"写指针的下一个位置是读指针"就算满,这样能通过指针位置直接区分空/满
- 这是环形缓冲区实现里最容易踩坑的地方,因为
- 如果写指针追上读指针(缓冲区满了)会发生什么
- 在
TCP发送缓冲区场景:send()调用会阻塞(或非阻塞模式下返回WSAEWOULDBLOCK),等内核真正把数据发出去、读指针往前挪出空间后才能继续写 - 在某些日志/监控场景:会选择直接覆盖最老的数据(这也是环形缓冲区在日志系统里常见的"只保留最近
N条"效果的实现原理)
- 在
- 为什么
TCP收发缓冲区偏偏要用这种结构,而不是普通数组- 避免频繁的内存分配和拷贝
- 天然贴合"流式"数据的读写模式
- 天然实现了"滑动窗口"的容量概念
这也是环形缓冲区大小和TCP流量控制直接挂钩的原因
边缘触发和水平触发
- 概述
- "边缘触发"和"水平触发"是
IO复用模型里一个非常核心、也很容易踩坑的概念
- "边缘触发"和"水平触发"是
- 这两个词从哪来的——电路信号的比喻
- 这两个术语最早来自数字电路里对信号触发方式的描述,拿到
IO事件通知上做类比,如下:
- 这两个术语最早来自数字电路里对信号触发方式的描述,拿到
- 水平触发(
Level Triggered, LT)- 只要信号处于"高电平"这个状态,就一直通知你
- 类比到
socket上就是:只要缓冲区里还有数据没读完,每次你去问"有数据吗",都会告诉你"有"
- 边缘触发(
Edge Triggered, ET)- 只在信号从低电平跳变到高电平的那一瞬间通知你一次,之后哪怕电平一直是高的,也不会再通知
- 类比到
socket上就是:只在数据从"无"变成"有"的那一刻通知你一次,之后不管你有没有读完,都不会再通知,除非又有新数据到达导致新的一次"跳变"
- 具体场景对比行为差异
- 水平触发准确定义
- 只要缓冲区里还有未读完的数据,每次你去查询/等待,内核都会持续告诉你"这个
socket可读" - 这是
select、poll的默认行为,也是Linuxepoll的默认模式
- 只要缓冲区里还有未读完的数据,每次你去查询/等待,内核都会持续告诉你"这个
- 边缘触发准确定义
- 只在数据状态发生"跳变"的那一刻通知一次——从"无数据"变成"有数据"这个瞬间
- 之后哪怕缓冲区里还有数据没读完,只要没有新数据到达(不再发生新的跳变),就不会再通知
- 这是
Linuxepoll的一个可选模式(EPOLLET标志) Windows下没有严格对应的epoll式ET模式,但WSAAsyncSelect的FD_READ事件行为上很接近ET
为什么要有ET这种"容易漏读"的模式
LT模式下- 内核每次
epoll_wait都要重新检查这个fd的状态是不是还"就绪",如果你的应用层处理慢、数据没读完,内核得反复维护和上报这个就绪状态
- 内核每次
ET模式下- 内核只需要在状态跳变那一刻通知一次,之后这个
fd可以从"活跃列表"里摘掉,不需要每次epoll_wait都重新检查它 - 在处理海量并发连接、且大部分连接的数据不频繁变化的场景下,
ET能显著减少内核不必要的重复检查开销
- 内核只需要在状态跳变那一刻通知一次,之后这个
- 这也是为什么高性能网络框架(比如
Nginx)默认倾向于用ET模式- 代价是编程复杂度更高(必须配合非阻塞+循环读逻辑),收益是减少了内核态的重复通知开销
IOCP和"触发模式"
- 概述
IOCP完全不属于"触发模式"这套体系,这是一个容易混淆的地方
LT/ET描述的是"就绪通知"模型- 内核告诉你"这个
socket现在可以读/写了,你自己去调recv/send"
- 内核告诉你"这个
IOCP是"完成通知"模型- 你先投递一个
WSARecv异步请求,内核在数据已经被拷贝进你提供的缓冲区之后才通知你"这次读操作完成了,数据已经在你手里了"
- 你先投递一个
- 设计理念不同
epoll本质是同步IO的多路复用(数据到没到你自己决定什么时候去读)IOCP是真正的异步IO(读这个动作本身也是异步发起、由内核完成的)
- 总结就是
epoll(LT/ET)是"我告诉你可以开始干活了"IOCP是"我已经帮你干完活了,来看结果吧"
声明:本文为原创文章,版权归Aet所有,欢迎分享本文,转载请保留出处!
你可能也喜欢
- ♥ Windbg:远程调试,dump捕获06/04
- ♥ 51CTO:C++网络通信引擎架构与实现一09/09
- ♥ Windows_API_Apply_112/31
- ♥ Soui四05/23
- ♥ 各平台调试方法总结记述一09/25
- ♥ Linux 高性能服务器编程:TCP二11/24




