• 忘掉天地
  • 仿佛也想不起自己
bingliaolongBingliaolong  2026-07-13 23:50 Aet 隐藏边栏 |   抢沙发  28 
文章评分 2 次,平均分 5.0

Windows下网络编程

Winsock

  1. Windows下的网络编程基于 WinsockWindows Sockets API),是对BSD socket的封装扩展,核心头文件是 winsock2.h,需要链接 ws2_32.lib

流程

  1. 基本流程和Linux socket类似
  2. 区别在于Windows提供了更丰富的I/O模型来解决高并发问题

socket到底创建了什么

  1. 调用 socket(AF_INET, SOCK_STREAM, IPPROTO_TCP) 时,操作系统内核做的事情是:
    1. 在内核里分配一个 socket结构体(Linux下是struct socket + struct sockWindows下是内核对象+TCP_ENDPOINT),这个结构体里包含:
    2. 本地地址/端口(此时还是空的,未绑定)
    3. 对端地址/端口(此时也是空的)
    4. TCP传输控制块(TCB):状态机(CLOSED状态)、序号计数器、窗口大小等
    5. 接收缓冲区、发送缓冲区(内核态的一块内存,此时是空的环形缓冲区)
  2. 内核把这个socket对象和一个文件描述符(Linux下是intWindows下是SOCKET句柄)关联起来,返回给用户程序
  3. 关键点:
    1. socket()这一步只是"申请了一个空白的通信端点",此时没有发生任何网络行为,纯粹是内核里分配了一块数据结构
    2. 这也是为什么"socket是内核对象"这个说法的来源——它本质上和文件、管道一样,是内核维护的一种资源,只是恰好这种资源的读写行为对应着网络收发

bind()做了什么

  1. 表面行为
    1. 调用后,内核把addr里的IP地址+端口号,写进了这个socket内核对象的本地地址字段里
    2. 这一步之后,这个socket就有了"本地身份":其他机器要发数据给你,得知道往这个IP+port

服务端为什么必须显式bind

  1. 服务端要监听一个固定、公开的端口(比如80、443、9000),这样客户端才能提前知道"连这个端口就能找到你"
  2. 如果不显式bind(),操作系统会在你listen()时自动分配一个随机端口——但客户端没法提前知道这个随机端口是多少,也就没法连接你,所以服务端必须主动指定一个固定端口

客户端为什么通常不需要显式bind

  1. 客户端只需要主动发起连接,不需要让别人"提前找到自己",所以客户端通常直接调用connect(),不用先bind()
  2. 但这不代表客户端的socket没有本地端口
    1. 内核在connect()内部,如果发现这个socket还没bind过,会自动帮你从临时端口范围(ephemeral port range)里挑一个空闲端口,隐式完成一次"隐式bind"

bind的几个具体底层动作

  1. 端口占用检查
    1. 内核会检查你要绑定的(IP, 端口, 协议)组合有没有被其他socket占用
    2. 如果冲突,bind()会返回错误——Windows下是WSAEADDRINUSELinux下是EADDRINUSE
    3. 这个检查本质上是查内核维护的一张"已绑定地址"哈希表/列表
  2. INADDR_ANY的特殊处理
    1. 这不是让内核"随便选一个IP",而是告诉内核:"这个socket在所有本机网卡的IP上都能接收数据"
    2. 比如一台服务器有内网IP 192.168.1.10和公网IP 1.2.3.4两块网卡,绑定INADDR_ANY意味着不管客户端是从内网还是公网连进来(只要端口对得上),这个socket都能收到
    3. 如果显式绑定成192.168.1.10,那么只有从这个网卡收到的、目标端口匹配的包,才会被投递给这个socket

  1. 端口号为0的特殊处理
    1. 如果你写sin_port = htons(0),内核会自动帮你从临时端口范围里挑一个可用端口(等同于不bind直接connect时内核做的事)
    2. 这也是为什么有些服务端会写bind()时端口填0,让操作系统去分配一个随机可用端口,常用于一些不关心固定端口号的场景
  2. SO_REUSEADDR
    1. 一个绕不开的常见坑
    2. 服务端程序重启时,经常会遇到bind()报错Address already in use,明明看起来端口没人用
    3. 这是因为TCP连接关闭后,操作系统会让这个"四元组"停留在TIME_WAIT状态一段时间(通常是2MSL2-4分钟),防止网络上滞留的旧数据包干扰新连接
    4. 段时间内,默认情况下你没法重新bind()同一个端口
    5. 解决方法是在bind()之前设置:
    6. 这个选项告诉内核:"即使这个端口有旧连接处于TIME_WAIT,也允许我重新绑定"
      这是几乎所有生产环境服务端程序的标配设置,不加的话你会发现每次重启服务都得等几分钟才能重新监听同一个端口,开发调试时会很痛苦

listen()做了什么

  1. 表面行为(调用后,内核做两件事)
    1. 把这个socket的状态从CLOSED改成LISTEN
    2. 在内核里为这个socket分配并初始化两个队列——这才是listen()真正的核心工作

listen两个队列的机制

listenbacklog参数

  1. 这是一个经常被误解的地方:
    1. backlog参数不是"最大能同时接受多少个客户端连接",而是Accept队列(全连接队列)的最大长度
    2. 也就是"已经完成三次握手、但你的程序还没来得及调用accept()取走"的连接最多能堆积多少个

  1. 为什么会有连接堆积在这里
    1. 如果你的服务端程序处理accept()的速度跟不上新连接到达的速度(比如CPU繁忙、或者单线程在处理别的事),已经握手成功的连接就会在Accept队列里排队等着,队列满了之后,新到达的握手完成的连接会被内核丢弃
    2. Windows下客户端会收到连接被拒绝或超时
    3. Linux下默认行为是丢弃对应的ACK,让客户端触发重传
  2. SOMAXCONN是系统定义的一个"建议最大值"
    1. Windows下这个值由系统限制且会被内部截断到一个上限(不同Windows版本上限不同)
    2. 实际生产环境很多人会填一个具体数字比如128512,根据预期并发连接速率调整

listen为什么要分两个队列而不是一个

  1. SYN Flood攻击防护
  2. 如果只有一个队列
    1. 攻击者可以发送大量伪造源IPSYN包(这叫SYN Flood攻击),服务端对每个SYN都要回复SYN+ACK并且分配资源等待
    2. 因为攻击者根本不会回复最后的ACK,这些"半连接"会一直占着队列位置直到超时,很容易把队列打满,导致正常用户的连接请求被拒绝
  3. 分成两个队列后
    1. SYN队列(半连接)和Accept队列(全连接)是分开管理的,操作系统可以针对"半连接队列"单独做优化,比如:
    2. SYN Cookies机制:SYN队列快满时,内核不再真正维护半连接状态,而是把连接信息编码进SYN+ACK的序号里发回去,等对方真的回复ACK时再从这个序号里解码还原出连接信息
      这样即使遭遇SYN Flood,也不需要真正为每个半连接分配内存,从而防御攻击(这是Linux tcp_syncookies选项的原理
    3. Windows下也有类似的SYN攻击保护机制,叫SYN attack protection,在注册表TcpMaxHalfOpen等参数里可调

listen()和端口状态的关系

  1. 当你执行netstat -ano看到某个端口状态是LISTENING,对应的正是这个socket调用了listen()之后的状态
  2. 这个状态意味着:
    1. 这个socket永远不会自己发起收发数据,它唯一的工作是"接受新连接请求,然后把完成握手的连接扔进Accept队列"
    2. 真正收发业务数据的,是accept()返回的那些新socket

accept()做了什么

  1. 函数签名和表面行为
    1. s:监听socket(已经listen()过的那个)
    2. addr:出参,内核会把发起连接的客户端地址信息填进来
    3. addrlen:输入输出参数,传入你缓冲区大小,返回实际填充的大小
    4. 返回值是一个全新的socket句柄

accept底层

  1. 一:从Accept队列头部取出一个连接
    1. listen()维护了一个Accept队列,里面存的是已经完成三次握手的连接
    2. accept()本质上就是从这个队列头部摘取一个节点
    3. 内核里这个队列通常实现为一个链表,摘取操作本身是O(1)
  2. 如果队列是空的:
    1. 阻塞模式下,accept()会阻塞等待,直到有新连接完成握手进入队列
    2. 非阻塞模式下,accept()立刻返回错误:Windows下是WSAEWOULDBLOCKLinux下是EWOULDBLOCK/EAGAIN
  3. 二:创建一个全新的socket内核对象
    1. 内核为这个已经握手成功的连接新建一个socket结构体(前面讲的struct sock / inet_sock / tcp_sock这一整套嵌套结构),并把这个新socket和一个新的文件描述符/句柄关联起来返回给你
  4. 这个新socket对象里已经提前填好了:
    1. 本地地址、本地端口(继承自监听socket的绑定地址)
    2. 对端地址、对端端口(就是发起连接的客户端信息)
    3. TCP状态:ESTABLISHED(因为握手已经在进入Accept队列之前就完成了)
    4. 序号信息:snd_nxtrcv_nxt等,握手过程中协商好的
    5. 换句话说
      accept()返回的这个新socket,从一"出生"就已经是一个五元组完整、状态是ESTABLISHED的成熟连接,不需要你再额外调用connect()之类的操作
  5. 三:填充客户端地址信息到你传入的addr参数
    1. 内核把这个连接对应的客户端IP和端口,从内核的连接记录里拷贝到你传入的sockaddr结构体里
    2. 这样应用层代码就能知道"这个新socket对应的是哪个客户端"
      (常见用法是拿这个信息打日志、做IP白名单/黑名单判断等)
  6. 四:把这个连接从Accept队列里移除
    1. 摘取完成后,这个连接节点从Accept队列的链表里断开,不会被下一次accept()重复取到

accept()返回的是一个新的socket对象

  1. accept()返回的是一个全新的socket对象,不是原来那个监听socket
    1. 监听socket自始至终只负责"排队等待新连接"
    2. 真正用来和某个具体客户端收发数据的,是accept()每次返回的新socket
  2. 这也是为什么高并发服务器里,一个监听socket可以同时对应成千上万个已连接的客户端socket,它们是各自独立的内核对象,只是共享同一个"入口"
  3. accept()返回新socket之后
    1. 内核会把这个连接的五元组注册进之前讲的全局连接哈希表(ehash)里
    2. 这样当后续网卡收到属于这个连接的数据包时,内核能在O(1)时间内,通过五元组直接定位到这个新socket,把数据放进它的接收缓冲区,而不是监听socket的缓冲区

connect为什么能建立连接

  1. sockaddr_in 的作用
    1. sockaddr_in 本身只是一个数据容器

  1. connect()底层发生的事情,按顺序:
    1. 路由查找
      内核根据sockaddr_in里的目标IP,查询本机的路由表,决定这个包该从哪个网卡发出去、下一跳网关是谁
    2. 分配本地端口和源IP
      如果之前没有bind()过,内核会自动从临时端口范围(ephemeral port,一般是1024-65535之间的一段)里挑一个空闲端口作为源端口,同时确定用哪个网卡的IP作为源地址
    3. ARP解析(如果需要)
      如果目标是同一局域网内的机器,内核需要知道目标IP对应的MAC地址才能把包发出去,这时候会发ARP广播询问"谁是这个IP",得到MAC地址后缓存起来
      如果目标不在同一网段,则用网关的MAC地址
    4. TCP三次握手(这才是"连接建立"的核心)
      客户端发送一个SYN包(带随机初始序号x),此时客户端TCP状态变成SYN_SENT
      服务端收到SYN,回复SYN+ACK(带自己的初始序号y,并确认x+1),服务端状态变成SYN_RCVD
      客户端收到后回复ACK(y+1),双方状态都变成ESTABLISHED

  1. connect()这个系统调用会阻塞(除非是非阻塞socket),直到这个三次握手完成(或超时失败),才返回给应用层

connect本质原理

  1. sockaddr_in提供了"要连去哪"的信息
  2. connect()触发内核完成了"路由决策 + 地址解析 + TCP握手"这一整套动作
  3. 握手成功后,内核把这个socket对象的状态从CLOSED改成ESTABLISHED,并且把对端地址、序号信息填进了socket结构体里
  4. 这样这个socket对象就从"空白的端点"变成了"绑定了具体一条连接的端点"

connect对端信息存放

  1. Linux下,一个TCP socket在内核里其实是层层嵌套的几个结构体,不是一个平铺的大结构:

  1. Linux内核用了一个很经典的C语言技巧
    1. struct tcp_sock的第一个成员就是struct inet_connection_sock,它的第一个成员又是struct inet_sock,再往上第一个成员是struct sock
    2. 因为第一个成员的内存地址和外层结构体的起始地址完全相同,所以内核可以直接把一个struct tcp_sock*指针强转成struct sock*来用
      反之则用container_of宏根据偏移量换算回去

  1. 关于对端信息存放:
    1. 对端地址(IP+端口) → 填进了 inet_sock 里的 inet_daddr(目的IP)和 inet_dport(目的端口)
    2. 序号信息 → 填进了 tcp_sock 里的 rcv_nxt(下一个期望收到的序号,也就是要回复的ACK号)和 snd_nxt(下一个要发送的序号)
    3. 连接状态从CLOSED变成ESTABLISHED → 改的是最外层 struct sock 里的 sk_state 字段
  2. Windows下的对应关系(TCP/IP协议栈是tcpip.sys,源码不公开)
    1. 虽然微软没公开源码,但从公开的WinDbg调试符号和文档能看到类似的分层设计
    2. 对端地址存在TCP Endpoint对象里
    3. 序号信息存在TCBTransmission Control Block)结构里
    4. 状态机同样是一个类似sk_state的枚举字段

socket全局连接哈希表

  1. 为什么recv()能立刻找到"该给哪个socket
    1. 内核维护一个全局的连接哈希表(ehashestablished hash table
    2. 每次网卡收到一个TCP包,内核会用这个包的五元组去查这张哈希表,直接定位到对应的struct sock指针,然后把数据挂到它的sk_receive_queue
    3. 这一步查找是O(1)级别的,不是遍历所有连接去找,这也是Linux网络栈能支撑高并发连接的关键设计之一

send()、recv()原理

  1. 关键前提:连接建立后,"身份"已经确定
    1. 三次握手完成后,这条连接在内核里由一个五元组唯一标识:
    2. 这个五元组被记录在socket对象的TCB
    3. 之后每一次send/recv,内核都不需要你再告诉它"发给谁"——因为socket对象本身已经"记住"了对端是谁
    4. 这也是send()函数不需要再传地址参数(而sendto()需要,因为UDP是无连接的)的原因

send() 做了什么

  1. 调用send(sock, buf, len, 0)时:
  2. 第一步:数据从用户态拷贝到内核态
    1. 你在应用层准备的buf是用户空间的内存,内核会把这段数据拷贝到这个socket对应的内核发送缓冲区(一块环形缓冲区,大小可以通过SO_SNDBUF调整)
  3. 第二步:TCP层封装
    1. TCP协议栈从发送缓冲区取出数据,按照MSS(最大报文段长度,一般1460字节左右)切分成一个或多个TCP段,每个段加上TCP头(包含序号、确认号、窗口大小、校验和等)
    2. 这个过程叫封装
  4. 第三步:IP层封装
    1. TCP段交给IP层,加上IP头(源IP、目的IPTTL等),形成IP数据包
  5. 第四步:链路层封装并通过网卡发出
    1. IP包交给网卡驱动,加上以太网帧头(源MAC、目的MAC),通过网卡的DMA机制把数据放到网卡的发送队列,网卡把电信号/光信号发到物理链路上

send()调用成功返回

  1. 并不代表数据已经发到对方那里,只代表"数据已经从你的用户缓冲区拷贝进了内核发送缓冲区"
  2. 真正把数据发出去,是TCP协议栈根据滑动窗口和拥塞控制算法自己决定的时机

recv() 做了什么

  1. 数据到达服务端网卡后,是一个反向的层层解封装过程
  2. 网卡收到数据
    1. 触发中断,把数据从网卡缓冲区DMA拷贝到内核的一块网络缓冲区(sk_buff结构,Linux下)
  3. 链路层剥掉以太网帧头
  4. IP层剥掉IP头,做一些校验(比如校验和、TTL),根据IP头判断这是TCP包,往上交给TCP
  5. TCP层剥掉TCP头,根据五元组找到对应的socket对象
    1. 这一步很关键——内核维护了一张连接哈希表,用五元组作为key,能快速定位到是哪个进程的哪个socket该接收这个数据
  6. TCP层做序号校验
    1. 是不是期望的下一个序号?是否需要重排序?是否需要发ACK确认?
    2. 确认无误后,把数据放进这个socket的内核接收缓冲区
  7. 如果这时候应用层正阻塞在recv()调用上(在等待数据),内核会唤醒这个等待的线程
  8. recv()函数把数据从内核接收缓冲区拷贝到你提供的用户态buf里,返回实际拷贝的字节数

recv() 本质原理

  1. 不是"主动去网络上取数据",而是"从内核已经放好的接收缓冲区里,把数据搬到用户空间"
  2. 真正从网线上收数据、做TCP协议处理,是网卡中断 + 内核协议栈在你调用recv()之前就已经异步完成的
  3. 这也是为什么会有阻塞/非阻塞、以及IOCP这类异步IO模型的意义
    1. 也就是说,recv()本质上只是"读一块已经准备好的内核内存",如果这块内存里没数据:
    2. 要么阻塞等待
    3. 要么(非阻塞模式)立刻返回错误
    4. 要么(IOCP)通过完成端口异步通知你"数据到了,可以来读了"

socket开发本质

概述

  1. socket API是唯一的底座,所有网络程序都要用它,你没法绕开socket()bind()listen()send()recv()这套接口去写网络程序
  2. 不管你是写最简单的单线程阻塞式程序,还是用epoll/IOCP写高并发服务器,最终都要调用这些函数

socket的状态

  1. 真正有"选择"的,是你怎么去管理这些socket的"读写就绪状态"
    1. 这才是IO模型要解决的问题

  1. 可读可写异常这三个状态
    1. selectWSAEventSelectepoll这些"就绪通知模型"的核心判断依据
    2. 所有这些模型本质上都是在替你反复问内核这三个问题

socket可读

  1. 触发条件1:接收缓冲区里有数据
    1. 最直观的情况:对端发了数据过来,内核把数据放进了这个socket的接收缓冲区,只要缓冲区不为空,就判定为"可读"
    2. 这时候调用recv()能读到数据,返回值大于0
  2. 触发条件2:对端正常关闭连接(收到FIN)——这一条最容易被忽略
    1. 当对端调用close()/shutdown(),会给你发一个TCPFIN
    2. 这个事件也会被判定为"可读"——但你去recv()的时候,会读到返回值为0,而不是读到数据

  1. 触发条件3:监听socket上有新连接完成握手,等待accept
    1. 监听socket本身没有"数据"可言,但它一样会触发可读事件
    2. 原因是accept()这个动作,本质上和recv()一样,都是"从某个队列里取东西、不阻塞地拿到结果",所以Berkeley socket的设计里,把"监听socket上有新连接可以accept"也归为"可读"这个大类
    3. 这也是为什么select模型里,你需要把监听socket也加进readfds集合,而不是单独搞一个"可accept"的集合
  2. 触发条件4:socket发生错误
    1. 如果这个连接因为网络问题(比如对端强制重置RST、网络中断超时)出现了错误,也会触发可读事件,你去recv()会得到一个错误码

为什么"连接关闭"也算可读

  1. "可读"这个状态本质是在问"我现在调用recv()会不会立刻返回(而不是卡住阻塞)"
  2. 不管是"有数据"还是"连接已关闭",这两种情况下recv()都会立刻返回而不阻塞,所以都归类到"可读"事件里

socket可写

  1. 触发条件1:发送缓冲区有可用空间
    1. 发送缓冲区没满,你调用send()能把数据写进去(不会因为缓冲区满了而阻塞)
  2. 触发条件2:socket刚创建时,默认就是可写的
    1. 一个刚建立好连接的socket,因为发送缓冲区是空的,从一开始就处于"可写"状态
    2. 这意味着如果你用select/epoll监视writefds/EPOLLOUT,socket建立的第一时刻这个事件几乎立刻会触发
    3. 大多数生产代码的做法是默认不监视EPOLLOUT/FD_WRITE,只有当某次send()因为缓冲区满了返回EWOULDBLOCK/WSAEWOULDBLOCK时,才临时注册可写事件,等缓冲区腾出空间后再继续发送剩余数据,发送完成后再取消监视,避免不必要的重复触发
  3. 触发条件3:非阻塞connect的完成通知
    1. 这是可写事件一个非常经典的用法
    2. 如果你用非阻塞模式发起connect(),函数会立刻返回(不等三次握手完成),真正握手完成(不管成功还是失败)的通知,是通过可写事件来告诉你的:
    3. 注意这里必须用getsockopt(SO_ERROR)来判断连接到底是成功还是失败,因为"可写事件触发"本身只代表"connect这个动作完成了",并不代表握手一定成功——握手失败(比如对端拒绝、超时)也会触发可写事件,只是SO_ERROR会带上具体的错误码

  1. 触发条件4:socket发生错误
    1. 和可读一样,socket出错时可写事件也可能被触发,同样需要检查SO_ERROR来确认具体错误原因

socket异常

  1. 用得最少、也最容易被误解的一类
  2. 唯一的核心触发条件:TCP带外数据(OOB, Out-Of-Band data)到达

带外数据(OOB, Out-Of-Band data)

  1. TCP协议里有一种叫"紧急指针(urgent pointer)"的机制,允许发送方标记一小段数据为"紧急数据",这段数据可以插队,让接收方即使还有正常数据排在前面没读,也能优先感知到这个紧急标记的存在
    1. 这就是所谓的"带外数据"
  2. 默认情况下(没有设置SO_OOBINLINE),带外数据不会和普通数据混在接收缓冲区里,而是通过"异常"这个独立的事件通道来通知你
    1. 应用层需要用recv(sock, buf, len, MSG_OOB)这个特殊标志来专门读取这段紧急数据
  3. 这个机制在现代网络编程里几乎不再使用,原因是:
    1. 带外数据在实际的TCP实现里语义比较模糊、各操作系统实现细节有差异,历史上出现过一些兼容性问题
    2. 现代应用层协议基本都是自己在应用层设计"优先级"或者"控制消息"机制(比如在协议头里加一个消息类型字段来区分普通数据和控制指令),而不依赖TCP协议本身的这个古老特性
  4. 所以你会发现,select/epollexceptfds/EPOLLPRI这类"异常事件"监视,在实际生产代码里出现的频率极低

三种状态在几个模型里的具体映射

  1. WindowsWSAEventSelect把"可读"这个大类拆得更细
状态 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模型

概述

  1. 按性能和复杂度从低到高

阻塞式(Blocking)

  1. 最简单,一个连接占一个线程,无法扩展

select模型

  1. 跨平台,但有fd数量限制(FD_SETSIZE默认64

WSAAsyncSelect

  1. 基于消息机制,适合GUI程序,性能一般

WSAEventSelect

  1. 基于事件对象,比select好但仍受WSA_MAXIMUM_WAIT_EVENTS(64)限制

重叠I/O(Overlapped I/O)

  1. 真正的异步I/O,是IOCP的基础

IOCP(I/O Completion Port)

  1. Windows平台性能最强的高并发网络模型

select模型

解决的问题

  1. 如果用纯阻塞式socket,一个线程调用recv()就会卡死等待,没法同时处理多个连接(除非一个连接开一个线程,但线程数一多,上下文切换开销就很大)
  2. select模型的核心思路:
    1. 让一个线程可以同时"盯着"一批socket,问操作系统"这些socket里,哪些现在可读、哪些可写、哪些出异常了?"
    2. 等内核告诉你答案后,你再针对"已经准备好"的那些socket调用recv/send,这样就不会白白卡在没数据的socket

核心数据结构

  1. fd_set本质就是一个socket句柄的集合
    1. Windows下的实现是数组,不是Linux下的位图,这点和Linux不太一样

  1. 围绕它有几个操作宏

select函数签名和语义

  1. 关键点
    1. select是阻塞调用(除非超时设为0),它会一直等到readfds/writefds/exceptfds里至少有一个socket就绪,或者超时,才返回
    2. 返回后,这几个fd_set会被原地修改
      内核把不再就绪的socket从集合里剔除,只留下真正就绪的那些,所以调用完之后你需要重新遍历,用FD_ISSET挨个检查谁还在集合里

code

select模型的致命缺陷

  1. 集合大小限制(FD_SETSIZE
    1. WindowsFD_SETSIZE默认是64,也就是一次最多同时监视64socket
    2. 虽然可以在包含winsock2.h之前#define FD_SETSIZE 1024来放大,但这需要重新编译Winsock相关代码,且过大之后性能会明显下降
  2. 每次调用都要在用户态和内核态之间来回拷贝整个集合
    1. 每次select()调用,都要把完整的fd_set从用户空间拷贝到内核空间,内核检查完再把结果拷贝回来
    2. socket数量越多,这个拷贝开销越大——这是O(n)级别的开销,n是你监视的socket总数
  3. 内核需要线性扫描整个集合
    1. 内核收到fd_set后,要逐个遍历里面每一个socket,检查它的状态是否就绪——这也是O(n)
    2. 如果你有1000个连接,其中只有1个有数据,select依然要把这1000socket全部检查一遍才能找出那一个
  4. 每次调用前都要重新构建fd_set
    1. 因为select返回后会修改传入的集合(把没就绪的socket踢出去),所以下一次调用前你必须重新调用FD_ZERO+FD_SET把所有关心的socket重新加一遍

和其他模型的对比

模型 一次调用的复杂度 最大连接限制 是否跨平台
select O(n) 拷贝+扫描 64Windows),1024Linux,可调) 是,BSD socket标准,Windows/Linux都支持
WSAEventSelect O(n) 扫描,但用事件对象 64WSA_MAXIMUM_WAIT_EVENTS限制) 否,Windows专有
epoll(Linux) O(1),只返回就绪的 理论无上限 否,Linux专有
IOCP(Windows) O(1),完成通知模型 理论无上限 否,Windows专有

select模型现在还有什么实际价值

  1. 说实话,在生产环境的高性能服务端里,select模型基本已经被淘汰
  2. 了解它的价值主要在于:
    1. 理解IO复用的最原始设计思路,再学epoll/IOCP这些优化方案时更容易明白"它们到底优化了select的哪些短板"
    2. 跨平台兼容性场景:因为selectBSD socket标准的一部分,WindowsLinux都支持,一些对性能要求不高、但要求"代码一套跑两个平台"的小工具,可能还会用select图个简单

wsaasyncselect

概述

  1. WSAAsyncSelectWindows特有的一种IO模型,专门为GUI应用程序设计的

WSAAsyncSelect解决的问题

  1. select模型有个明显问题:
    1. 它是阻塞调用(或者你得不断轮询),如果你在一个有界面的Windows程序(比如MFC/Win32窗口程序)里直接调用select(),主线程会被卡住,导致界面卡死、无响应
    2. 因为WindowsGUI程序依赖一个"消息循环"来处理鼠标点击、窗口重绘等事件,你不能让这个循环被网络IO卡住
  2. WSAAsyncSelect的思路是:
    1. socket事件转换成Windows消息,塞进你程序原本就有的消息循环里去处理,这样完全不需要额外阻塞或者轮询,网络事件和界面事件用同一套消息机制统一处理

函数签名和使用方式

  1. lEvent可以是这些事件的组合

底层原理——事件如何变成窗口消息

  1. 第一步:注册阶段
    1. 调用WSAAsyncSelect时,Winsock内部做了两件事:
    2. 把这个socket自动设置成非阻塞模式(这是隐式发生的,不需要你额外调ioctlsocket
    3. (socket, 窗口句柄, 消息编号, 关心的事件)这组关系记录在Winsock内部维护的一张表里
  2. 第二步:内核检测到网络事件发生
    1. 比如网卡收到了数据,TCP协议栈处理完之后,发现这个socket正被WSAAsyncSelect监视,并且这次事件(比如"有数据可读")正好是注册时关心的FD_READ类型
  3. 第三步:Winsock通过PostMessage把事件封装成一条Windows消息,塞进你窗口的消息队列

  1. 第四步:你的消息循环收到这条消息,正常处理

和其他模型的对比与局限

  1. 相比阻塞式/select
    1. 不会卡住主线程,天然适合和GUI消息循环整合
  2. 相比IOCPWSAAsyncSelect有明显的性能短板:
    1. 消息队列的开销:每个网络事件都要走一遍PostMessage → 消息队列 → GetMessage → DispatchMessage这套完整的Windows消息机制,比IOCP直接的完成通知开销大得多
    2. 单线程消息循环是瓶颈:所有socket事件最终都堆到同一个窗口的消息队列里,由一个线程串行处理,没法像IOCP那样用线程池并行处理多个完成事件,高并发下这是致命短板
    3. FD_READ语义容易踩坑:FD_READ是"边缘触发"式的通知——只在数据到达那一刻通知一次,如果你收到通知后没有一次性把数据读完,剩下的数据不会重复触发新的FD_READ消息(除非有新数据到达)
      select的"水平触发"(只要还有数据没读完,每次调用都会告诉你可读)行为不同,是这个模型一个容易被忽略的坑点

实际使用场景定位

  1. WSAAsyncSelect基本上只适合连接数不多、且本身就是GUI程序的场景
  2. 比如:
    1. 早期的MFC聊天工具、下载工具客户端(连接数通常个位数到几十)
    2. 一些需要在界面程序里做简单网络通信、但又不想开额外线程处理网络IO的小工具

wsaeventselect

概述

  1. WSAEventSelectWindows网络编程演进链条里,继WSAAsyncSelect之后的下一步
  2. 它解决了WSAAsyncSelect"必须依赖窗口消息循环"这个限制,让非GUI程序也能用事件驱动的方式做网络IO,同时还没有IOCP那么复杂

WSAEventSelect解决的问题

  1. WSAAsyncSelect有个硬性要求
    1. 必须有一个窗口句柄和消息循环,这对纯后台服务程序、控制台程序不太友好
    2. 总不能为了收发网络数据,专门造一个隐藏窗口去跑消息循环(虽然技术上可以这么做,但显得别扭)
  2. WSAEventSelect换了一种通知机制
    1. 不用Windows消息,而是用Windows的通用同步对象——事件对象(Event Object) 来通知网络事件,这样任何线程(不需要消息循环)都可以用标准的WaitForMultipleObjects来等待网络事件,和等待其他内核对象(互斥锁、信号量)用的是同一套机制

核心API和数据结构

  1. 第一步:创建一个事件对象

  1. 第二步:把socket和这个事件对象、关心的事件类型绑定
    1. socket自动设成非阻塞模式(和WSAAsyncSelect一样是隐式的)
    2. 告诉内核:"这个socket上如果发生了lNetworkEvents里指定的任何一种事件,就把hEventObject这个事件对象设置成'有信号'状态"

  1. 第三步:用WSAWaitForMultipleEvents等待事件发生
    1. 这个函数本质是WaitForMultipleObjects的Winsock封装,会阻塞等待,直到lphEvents数组里任意一个事件对象被置为有信号状态(或超时)
    2. 返回值告诉你是数组里第几个事件触发了

  1. 第四步:用WSAEnumNetworkEvents查出具体是哪种事件、清除信号状态
    1. 这一步很关键:事件对象只是一个"有没有发生过某种事件"的信号灯,具体发生的是FD_READ还是FD_WRITE还是FD_CLOSE,需要靠WSAEnumNetworkEvents来查询
    2. 而且这个函数调用完之后,会自动把事件对象重置为无信号状态,为下一次事件做准备

code

致命限制:WSA_MAXIMUM_WAIT_EVENTS = 64

  1. 这是WSAEventSelect模型最大的短板,直接决定了它没法用在真正高并发的场景
    1. WSAWaitForMultipleEvents(本质是WaitForMultipleObjects)一次最多只能等待64个事件对象
    2. 这是Windows内核本身的限制,不是Winsock特意设的
    3. 这意味着一个线程最多同时管理64个连接
  2. 绕过方法
    1. 如果连接数超过64,只能开多个线程,每个线程各自维护一组不超过64个的事件数组
    2. 但这样又回到了"一个连接分组占一个线程"的老问题,只是从"一连接一线程"变成了"64连接一线程"
    3. 相比IOCP用固定线程池就能支撑成千上万连接,伸缩性差了一个数量级

FD_READ的边缘触发陷阱

  1. WSAEventSelectFD_READ事件和WSAAsyncSelect一样,行为上接近边缘触发
  2. 只在数据从无到有的那一刻触发一次,如果你这次没有把数据读完(比如缓冲区只读了一半),不会因为还有剩余数据就重复触发,除非又有新数据到达

现在还有什么实际价值

  1. WSAAsyncSelect类似,WSAEventSelect在真正的生产级高并发服务端里基本不会被选用
  2. 它的价值主要在于
    1. 理解"就绪通知"和"完成通知"之间的一个中间形态
    2. 一些工具类小程序或者连接数明确不会超过64的场景,比如内部管理工具、连接数可控的代理程序,图它比裸select好写、又不想直接上IOCP这种复杂度的场景

其他

环形缓冲区

  1. 概述
    1. "环形缓冲区"(Ring Buffer / Circular Buffer)是一种非常经典的数据结构
  2. 核心思想
    1. 普通数组用完了就是用完了,要么报错要么申请更大内存重新拷贝
    2. 环形缓冲区把一块固定大小的连续内存,逻辑上首尾相连成一个"环",用两个指针(读指针、写指针)分别标记"读到哪了"和"写到哪了",写指针写到数组末尾后,会绕回数组开头继续写
    3. 只要读指针跟得上,这块固定内存就可以被反复循环利用,不需要重新分配内存
  3. 实现原理
    1. 底层通常就是一个普通数组 + 两个索引
    2. writePos加到等于SIZE时,取模后自动变回0,逻辑上就相当于"绕回了数组开头",不需要真的做任何数据搬移

  1. 写入数据
    1. % SIZE这个取模运算就是"环形"效果的核心

  1. 读取数据

  1. 怎么判断缓冲区是空还是满
    1. 这是环形缓冲区实现里最容易踩坑的地方,因为readPos == writePos时,既可能表示"空的",也可能表示"满的"(转了一整圈正好追上)
    2. 常见解决方式有两种:
    3. 额外维护一个计数器(记录当前存了多少字节),不单纯依赖指针相对位置
    4. 故意浪费一个位置:约定"写指针的下一个位置是读指针"就算满,这样能通过指针位置直接区分空/满
  2. 如果写指针追上读指针(缓冲区满了)会发生什么
    1. TCP发送缓冲区场景:send()调用会阻塞(或非阻塞模式下返回WSAEWOULDBLOCK),等内核真正把数据发出去、读指针往前挪出空间后才能继续写
    2. 在某些日志/监控场景:会选择直接覆盖最老的数据(这也是环形缓冲区在日志系统里常见的"只保留最近N条"效果的实现原理)
  3. 为什么TCP收发缓冲区偏偏要用这种结构,而不是普通数组
    1. 避免频繁的内存分配和拷贝
    2. 天然贴合"流式"数据的读写模式
    3. 天然实现了"滑动窗口"的容量概念
      这也是环形缓冲区大小和TCP流量控制直接挂钩的原因

边缘触发和水平触发

  1. 概述
    1. "边缘触发"和"水平触发"是IO复用模型里一个非常核心、也很容易踩坑的概念
  2. 这两个词从哪来的——电路信号的比喻
    1. 这两个术语最早来自数字电路里对信号触发方式的描述,拿到IO事件通知上做类比,如下:
  3. 水平触发(Level Triggered, LT
    1. 只要信号处于"高电平"这个状态,就一直通知你
    2. 类比到socket上就是:只要缓冲区里还有数据没读完,每次你去问"有数据吗",都会告诉你"有"
  4. 边缘触发(Edge Triggered, ET
    1. 只在信号从低电平跳变到高电平的那一瞬间通知你一次,之后哪怕电平一直是高的,也不会再通知
    2. 类比到socket上就是:只在数据从"无"变成"有"的那一刻通知你一次,之后不管你有没有读完,都不会再通知,除非又有新数据到达导致新的一次"跳变"
  5. 具体场景对比行为差异

  1. 水平触发准确定义
    1. 只要缓冲区里还有未读完的数据,每次你去查询/等待,内核都会持续告诉你"这个socket可读"
    2. 这是selectpoll的默认行为,也是Linux epoll的默认模式
  2. 边缘触发准确定义
    1. 只在数据状态发生"跳变"的那一刻通知一次——从"无数据"变成"有数据"这个瞬间
    2. 之后哪怕缓冲区里还有数据没读完,只要没有新数据到达(不再发生新的跳变),就不会再通知
    3. 这是Linux epoll的一个可选模式(EPOLLET标志)
    4. Windows下没有严格对应的epollET模式,但WSAAsyncSelectFD_READ事件行为上很接近ET

为什么要有ET这种"容易漏读"的模式

  1. LT模式下
    1. 内核每次epoll_wait都要重新检查这个fd的状态是不是还"就绪",如果你的应用层处理慢、数据没读完,内核得反复维护和上报这个就绪状态
  2. ET模式下
    1. 内核只需要在状态跳变那一刻通知一次,之后这个fd可以从"活跃列表"里摘掉,不需要每次epoll_wait都重新检查它
    2. 在处理海量并发连接、且大部分连接的数据不频繁变化的场景下,ET能显著减少内核不必要的重复检查开销
  3. 这也是为什么高性能网络框架(比如Nginx)默认倾向于用ET模式
    1. 代价是编程复杂度更高(必须配合非阻塞+循环读逻辑),收益是减少了内核态的重复通知开销

IOCP和"触发模式"

  1. 概述
    1. IOCP完全不属于"触发模式"这套体系,这是一个容易混淆的地方
  2. LT/ET描述的是"就绪通知"模型
    1. 内核告诉你"这个socket现在可以读/写了,你自己去调recv/send"
  3. IOCP是"完成通知"模型
    1. 你先投递一个WSARecv异步请求,内核在数据已经被拷贝进你提供的缓冲区之后才通知你"这次读操作完成了,数据已经在你手里了"
  4. 设计理念不同
    1. epoll本质是同步IO的多路复用(数据到没到你自己决定什么时候去读)
    2. IOCP是真正的异步IO(读这个动作本身也是异步发起、由内核完成的)
  5. 总结就是
    1. epoll(LT/ET)是"我告诉你可以开始干活了"
    2. IOCP是"我已经帮你干完活了,来看结果吧"

声明:本文为原创文章,版权归所有,欢迎分享本文,转载请保留出处!

bingliaolong
Bingliaolong 关注:0    粉丝:0 最后编辑于:2026-07-16
Everything will be better.

发表评论

表情 格式 链接 私密 签到
扫一扫二维码分享