• 忘掉天地
  • 仿佛也想不起自己
bingliaolongBingliaolong  2026-07-13 23:50 Aet 隐藏边栏 |   抢沙发  1 
文章评分 1 次,平均分 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模型要解决的问题

socket可读

socket可写

socket异常

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

wsaeventselect

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

bingliaolong
Bingliaolong 关注:0    粉丝:0
Everything will be better.

发表评论

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