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模型要解决的问题
- 这才是
socket可读
socket可写
socket异常
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
wsaeventselect
声明:本文为原创文章,版权归Aet所有,欢迎分享本文,转载请保留出处!
你可能也喜欢
- ♥ Windows_API_Apply_112/31
- ♥ Dump分析:重复释放堆内存,死锁03/17
- ♥ Linux 线程概述&&创建03/31
- ♥ Linux 内核空间&&用户空间03/30
- ♥ Linux 高性能服务器编程:HTTP相关11/27
- ♥ Windows 核心编程 _ 内核对象:线程同步二07/30


