C# Socket编程模型

笔记哥 / 05-22 / 25点赞 / 0评论 / 325阅读
# 简介 Socket(套接字)是计算机网络中的一套编程接口,是网络编程的核心,它将复杂的网络协议封装为简单的API,是应用层(HTTP)与传输层(TCP)之间的桥梁。 应用程序通过调用Socket API,比如connect、send、recv,无需处理IP包封装,路由选择等复杂网络操作,`屏蔽底层细节`将网络通信简化为`建立连接-数据接收-数据发送-连接断开`,降低了开发复杂度。 ![image](https://cdn.res.knowhub.vip/c/2505/22/09437d50.png?G1YAAMTydJz4v%2ftX0m3UQZsoEpoBiSyCSgnr9Z6z9i3y%2fY1Kj8%2bofbb94S%2b1zybQ4oYsVCY1BE%2bApEORAz0ZcBWLazQ%3d) ## FD&Handle 1. **FD** 文件描述符,在linux系统中,`一切皆文件`,它是内核为了管理已打开的文件,而给每个进程维护的一个文件描述符表,而FD就是一个文件的索引。 2. **Handle** 而在windows平台下,这个概念被称为`Handle(句柄)`,都为应用程序提供了一种统一的方式来访问和操作资源,隐藏了底层资源管理的复杂性。 > > > FD主要用于标识文件、套接字、管道等输入输出资源;而Handle的应用范围更广,除了文件和网络资源外,还可以用于标识窗口、进程、线程、设备对象等各种系统资源。 > # Socket 网络模型 ## BIO,Blocking I/O BIO 是最传统的 I/O 模型,其`核心特征是一个连接一个线程`,线程在读取/写入时会阻塞,直到I/O操作完成。 ```csharp private static Socket _server; private static byte[] _buffer = new byte[1024 * 4]; static void Main(string[] args) { _server=new Socket(AddressFamily.InterNetwork,SocketType.Stream, ProtocolType.Tcp); _server.Bind(new IPEndPoint(IPAddress.Any, 6666)); _server.Listen(); while (true) { //BIO核心,线程阻塞,等待客户端连接 var client = _server.Accept(); Console.WriteLine($"Client {client.RemoteEndPoint} connect. "); //BIO核心,线程阻塞,等待客户端发送消息 var messageCount = client.Receive(_buffer); var message = Encoding.UTF8.GetString(_buffer, 0, messageCount); Console.WriteLine($"Client {client.RemoteEndPoint} Say:{message}"); } } ``` > > > 从代码中可以看出,有两个地方阻塞,一是Accept(),二是Receive(),如果客户端一直不发送数据,那么线程会一直阻塞在Receive()上,也不会接受其它客户端的连接。 > > ![image](https://cdn.res.knowhub.vip/c/2505/22/843120fd.png?G1cAAMTsdJxI8gm026hD2jvFHc2ARRpBpYT1es9Z%2byb6fgfD4jNan74%2f%2fKb16SRcTaUQGIkTQgCEDVo51yCXZiiKIa7h) > ### C10K问题 有聪明的小伙伴会想到,我可以利用多线程来处理Receive(),这样就服务端就可以接受其它客户端的连接了。 ```csharp internal class Program { private static Socket _server; private static byte[] _buffer = new byte[1024 * 4]; static void Main(string[] args) { _server=new Socket(AddressFamily.InterNetwork,SocketType.Stream, ProtocolType.Tcp); _server.Bind(new IPEndPoint(IPAddress.Any, 6666)); _server.Listen(); while (true) { //BIO核心,线程阻塞,等待客户端连接 var client = _server.Accept(); Console.WriteLine($"Client {client.RemoteEndPoint} connect. "); //多线程读取客户端数据,避免主线程阻塞 Task.Run(() => HandleClient(client)); } } static void HandleClient(Socket client) { while (true) { //BIO核心,线程阻塞,等待客户端发送消息 var messageCount = client.Receive(_buffer); var message = Encoding.UTF8.GetString(_buffer, 0, messageCount); Console.WriteLine($"Client {client.RemoteEndPoint} Say:{message}"); } } } ``` > > > 当给客户端建立好连接后,会启用一个新的线程来单独处理Receive(),避免了主线程阻塞。 > > 但有一个严重的缺陷,就是当一万个客户端同时连接,服务端要创建一万个线程来接。一万个线程带来的CPU上下文切换与内存成本,非常容易会拖垮服务器。这就是C10K问题来由来。 > 因此,BIO的痛点在于: 1. 高并发下资源耗尽 当连接数激增时,线程数量呈线性增长(如 10000 个连接对应 10000 个线程),导致内存占用过高、上下文切换频繁,系统性能急剧下降。 2. 阻塞导致效率低下 线程在等待 IO 时无法做其他事情,CPU 利用率低。 ## NIO,Non-Blocking I/O 为了解决此问题,需要跪舔操作系统,`为用户态程序提供一个真正非阻塞的Accept/Receive的函数`。 该函数的效果应该是,当没有新连接/新数据到达时,不阻塞线程。而是`返回一个特殊标识`,来告诉线程没有活干。 > > > Java 1.4 引入 NIO,C# 通过Begin/End异步方法或SocketAsyncEventArgs实现类似逻辑。 > ```csharp internal class Program { private static Socket _server; private static byte[] _buffer = new byte[1024 * 4]; //所有客户端的连接 private static readonly List _clients = new List(); static void Main(string[] args) { _server=new Socket(AddressFamily.InterNetwork,SocketType.Stream, ProtocolType.Tcp); _server.Bind(new IPEndPoint(IPAddress.Any, 6666)); _server.Listen(); //NIO核心,设为非阻塞模式 _server.Blocking = false; while (true) { try { var client = _server.Accept(); _clients.Add(client); Console.WriteLine($"Client {client.RemoteEndPoint} connect. "); } catch (SocketException ex) when(ex.SocketErrorCode==SocketError.WouldBlock) { //没有新连接时,调用Accept触发WouldBlock异常,无视即可。 } //一个线程同时管理Accept与Receive,已经有了多路复用的意思。 HandleClient(); } } static void HandleClient() { //一个一个遍历,寻找可用的客户端, foreach (var client in _clients.ToList()) { try { //NIO核心,非阻塞读取数据,无数据时立刻返回 var messageCount = client.Receive(_buffer, SocketFlags.None); var message = Encoding.UTF8.GetString(_buffer, 0, messageCount); Console.WriteLine($"Client {client.RemoteEndPoint} Say:{message}"); } catch (SocketException ex) when (ex.SocketErrorCode == SocketError.WouldBlock) { //没有新数据读取时,调用Receive触发WouldBlock异常,无视即可。 } } } } ``` > > > 通过NIO,我们可以非常惊喜的发现。我们`仅用了一个线程就完成对客户端的连接与监听`,相对BIO有了质的变化。 > > 当数据未就绪时(内核缓冲区无数据),非阻塞模式下的Accept/Receive会立即返回WouldBlock异常(或-1);当数据就绪时,调用会立即返回读取的字节数(>0),不会阻塞线程。数据从内核缓冲区到用户缓冲区的拷贝由 CPU 同步完成,属于正常 IO 操作流程,不涉及线程阻塞 > ![image](https://cdn.res.knowhub.vip/c/2505/22/c916482a.png?G1cAAER17rxgXRmgfice0wSBBJsBizSCSgnr9fz%2f2pfI%2bzlBi%2fdoffr%2b8JvWp4visKRVCGZkhEAqzKhACdRkRDkr4xoO) 尽管NIO已经是JAVA世界的绝对主流,但依旧存在几个痛点: 1. 轮询开销 如果事件比较少,轮询会产生大量空转,CPU资源被浪费。 2. 需要手动处理细节 比如手动编写捕获when (ex.SocketErrorCode == SocketError.WouldBlock)来识别状态, 需要手动处理TPC粘包,以及各种异常处理。 ## AIO,Asynchronous I/O AIO作为`大魔王与终极优化`,实现了真正的异步操作,当发起IO请求后,内核完全接管IO处理,完成后通过回调或者事件来通知程序,开发者无需关心缓冲区管理、事件状态跟踪或轮询开销。 > > > Java 7 引入 NIO.2(AIO),C# 通过IOCP+Async来实现 > ```csharp internal class Program { private static Socket _server; private static Memory _buffer = new byte[1024 * 4]; //所有客户端的连接 private static readonly List _clients = new List(); static async Task Main(string[] args) { _server=new Socket(AddressFamily.InterNetwork,SocketType.Stream, ProtocolType.Tcp); _server.Bind(new IPEndPoint(IPAddress.Any, 6666)); _server.Listen(); while (true) { //异步等待连接,线程不阻塞 var client = await _server.AcceptAsync(); //不阻塞主线程,由线程池调度 HandleClientAsync(client); } } private static async Task HandleClientAsync(Socket client) { //异步读取数据,由操作系统完成IO后唤醒 var messageCount = await client.ReceiveAsync(_buffer); var message = Encoding.UTF8.GetString(_buffer.ToArray(), 0, messageCount); Console.WriteLine($"Client {client.RemoteEndPoint} Say:{message}"); } } ``` ![image](https://cdn.res.knowhub.vip/c/2505/22/c416a453.png?G1cAAMTsdJxIPqKi26hD2jvFHc2ARRpBpYT1es9Z%2byb6fhcWjc9offr%2b8JvWpxPYNKGQsGTOCEEECpNaiwVALwMbI67h) # Linux/Windows对模型的支持 ![image](https://cdn.res.knowhub.vip/c/2505/22/d085ee87.png?G1YAAMTydJz4%2b%2fNCu406fJsoEpoBiSyCSgnr9Z6z9i3y%2fU7Q4jNan74%2f%2fKX16aKolrQIwYyM4Em1VIFSNChzNVxEXMMB) > > > IOCP:nput/Output Completion Port,I/O完成端口 > > .NET Core在Windows下基于IOCP,在Linux下基于epoll,在macOS中基于kqueue > ## NIO的改良,IO multiplexing I/O Multiplexing 是一种高效处理多个I/O操作的技术,核心思想是通过`少量线程`管理多个I/O流,避免因为单个I/O阻塞导致整体服务性能下降。 它通过`事件机制(可读,可写,异常)`监听多个I/O源,当某个I/O流可操作时,才对其执行读写操作,从而实现`单线程处理多连接`的高效模型。 > > > IO 多路复用本质是NIO的改良 > ### select/poll 参考上面的代码,HandleClient方法中,我们遍历了整个\_Clients,用以寻找客户端的Receive。 同样是C10K问题,如果我们1万,甚至100万个客户端连接。那么遍历的效率太过低下。尤其是每调用一次Receive都是一次`用户态到内核态`的切换。 那么,`如果让操作系统告诉我们,哪些连接是可用的`,我们就`避免了在用户态遍历`,从而提高性能。 ![image](https://cdn.res.knowhub.vip/c/2505/22/dfc38a93.gif?G1cAAMTc2vOlu20utd%2fToCVYBtoMWKQRVEpYr2fvf51E%2fXWBpHh9bUxfH37TxnRilKScSSAGQwgizGCttSIwoNnsyCV6vwE%3d) ```csharp /// /// 伪代码 /// static void HandleClientSelect() { var clients = _clients.ToList(); //自己不遍历,交给内核态去遍历. //这里会有一次list copy到内核态的过程,如果list量很大,开销也不小. var readyClients= Socket.Select(clients); //内核会帮你标记好哪些client已经就绪 foreach (var client in readyClients) { //用户态依旧需要遍历一遍,但避免无意义的系统调用,用户态到内核态的切换.只有真正就绪的client才处理 if (client.IsReady) { var messageCount = client.Receive(_buffer, SocketFlags.None); var message = Encoding.UTF8.GetString(_buffer, 0, messageCount); Console.WriteLine($"Client {client.RemoteEndPoint} Say:{message}"); } else { break; } } } ``` > > > 通过监听一组文件描述符(File Descriptor, FD)的可读、可写或异常状态,当其中任意状态满足时,内核返回就绪的 FD 集合。用户需遍历所有 FD 判断具体就绪的 I/O 操作。 > > > > select模型受限于系统默认值,最大只能处理1024个连接。poll模型通过结构体数组替代select位图的方式,避免了数量限制,其它无区别。 > ### epoll 作为NIO的`终极解决方案`,它解决了什么问题? 1. 调用select需要传递整个List var readyClients= Socket.Select(clients); 如果list中有10W+,那么这个copy的成本会非常高 2. select依旧是线性遍历 在内核层面依旧是遍历整个list,寻找可用的client,所以时间复杂度不变O(N),只是减少了从用户态切换到内核态的次数而已 3. 仅仅对ready做标记,并不减少返回量 select仅仅返回就绪的数量,具体是哪个就绪,还要自己遍历一遍。 所以epoll模型主要主要针对这三点,做出了如下优化: 1. 通过mmap,zero copy,减少数据拷贝 2. 不再通过轮询方式,而是通过异步事件通知唤醒,内部使用红黑树来管理fd/handle 3. 唤醒后,仅仅返回有变化的fd/handle,用户无需遍历整个list 基于**事件驱动(Event-Driven)**机制,内核维护一个 FD 列表,通过epoll\_ctl添加 / 删除 FD 监控,epoll\_wait阻塞等待就绪事件。就绪的 FD 通过事件列表返回,用户仅需处理就绪事件对应的 FD。 ![image](https://cdn.res.knowhub.vip/c/2505/22/4515f41a.gif?G1YAAMTc2vOl285Nrd%2fToCVYBtoMSGQRVEpYr2fvf51E%2fXUwNF5fG9PXh7%2b0MZ2EqyYpBIaxIXhA1ERgGeGwWowtafR%2bAw%3d%3d) 点击查看代码 ```csharp #include #include #include #include #include #include #include #include #include #include #include #include #define SEVER_PORT 6666 #define BUFFER_SIZE 1024 #define MAX_EVENTS 10 #define handle_error(cmd,result)\ if(result<0){ \ perror(cmd); \ exit(EXIT_FAILURE); \ } \ char *read_buf=NULL; char *write_buf=NULL; void init_buf() { read_buf=malloc(sizeof(char)* BUFFER_SIZE); //读内存分配判断 if(!read_buf) { printf("读缓存创建异常,断开连接\n"); exit(EXIT_FAILURE); } //写内存分配判断 write_buf=malloc(sizeof(char)* BUFFER_SIZE); if(!write_buf) { printf("写缓存创建异常,断开连接\n"); exit(EXIT_FAILURE); } memset(read_buf,0,BUFFER_SIZE); memset(write_buf,0,BUFFER_SIZE); } void clear_buf(char *buf) { memset(buf,0,BUFFER_SIZE); } void set_nonblocking(int sockfd) { int opts=fcntl(sockfd,F_GETFL); if(opts<0) { perror("fcntl(F_GETFL)"); exit(EXIT_FAILURE); } opts|=O_NONBLOCK; int res=fcntl(sockfd,F_SETFL,opts); if(res<0) { perror("fcntl(F_GETFL)"); exit(EXIT_FAILURE); } } int main(int argc, char const *argv[]) { //初始化读写缓冲区 init_buf(); //声明sockfd,clientfd int sockfd,client_fd,temp_result; //声明服务端与客户端地址 struct sockaddr_in server_addr,client_addr; memset(&server_addr,0,sizeof(server_addr)); memset(&client_addr,0,sizeof(client_addr)); //声明IP协议 server_addr.sin_family=AF_INET; //绑定主机地址 server_addr.sin_addr.s_addr=htonl(INADDR_ANY); //绑定端口 server_addr.sin_port=htons(SEVER_PORT); //创建socket sockfd=socket(AF_INET,SOCK_STREAM,0); handle_error("socket",sockfd); //绑定地址 temp_result=bind(sockfd,(struct sockaddr *)&server_addr,sizeof(server_addr)); handle_error("bind",temp_result); //进入监听 temp_result=listen(sockfd,128); handle_error("listen",temp_result); //将sockfd设为非阻塞模式 set_nonblocking(sockfd); int epollfd,nfds; struct epoll_event ev,events[MAX_EVENTS]; //创建epoll epollfd=epoll_create1(0); handle_error("epoll_create1",epollfd); //将sockfd加入到监控列表 ev.data.fd=sockfd; //将关联的文件描述符设为可读,可读说明有连接进入,就会被epoll触发 ev.events=EPOLLIN; temp_result=epoll_ctl(epollfd,EPOLL_CTL_ADD,sockfd,&ev); handle_error("epoll_ctl",temp_result); socklen_t client_addr_len=sizeof(client_addr); //接受client连接 while (1) { //挂起等待,有可读信息 //nfds表示有多少个客户端连接与多少条消息 nfds=epoll_wait(epollfd,events,MAX_EVENTS,-1); handle_error("epoll_wait",nfds); for (int i = 0; i < nfds; i++) { //第一个是sockfd,要预处理一下。 if(events[i].data.fd==sockfd) { client_fd=accept(sockfd,(struct sockaddr *)&client_addr,&client_addr_len); handle_error("accept",client_fd); set_nonblocking(client_fd); printf("与客户端from %s at PORT %d 文件描述符 %d 建立连接\n",inet_ntoa(client_addr.sin_addr),ntohs(client_addr.sin_port),client_fd); //将获取到的client连接也添加到监控列表 ev.data.fd=client_fd; ev.events=EPOLLIN|EPOLLET; epoll_ctl(epollfd,EPOLL_CTL_ADD,client_fd,&ev); } //既有新的客户端连接,又有旧客户端发送消息 else if(events[i].events&EPOLLIN) { //老连接有数据 int count=0,send_count=0; client_fd=events[i].data.fd; while ((count=recv(client_fd,read_buf,BUFFER_SIZE,0)>0)) { printf("receive message from client_fd: %d: %s \n",client_fd,read_buf); clear_buf(read_buf); strcpy(write_buf,"receive~\n"); send_count=send(client_fd,write_buf,strlen(write_buf),0); handle_error("send",send_count); clear_buf(write_buf); } if(count==-1&&errno==EAGAIN) { printf("当前批次已经读取完毕。\n"); } else if(count==0) { printf("客户端client_fd:%d请求关闭连接......\n",client_fd); strcpy(write_buf,"recevie your shutdown signal 收到你的关闭信号\n"); send_count=send(client_fd,write_buf,strlen(write_buf),0); handle_error("send",send_count); clear_buf(write_buf); //从epoll文件描述法符中移除该client_fd epoll_ctl(epollfd,EPOLL_CTL_DEL,client_fd,NULL); printf("释放client_fd:%d资源\n",client_fd); shutdown(client_fd,SHUT_WR); close(client_fd); } } } } printf("服务端关闭后资源释放\n"); close(epollfd); close(sockfd); free(read_buf); free(write_buf); return 0; } ``` # 理论与现实的割裂 从上面的理论可以看出,AIO似乎是`版本答案`,在C#中,AIO已经充斥着每一个角落,但在JAVA的世界中,更加主流的是NIO,这是为什么呢? **1. Linux的支持不足** Linux 内核直到 3.11 版本(2013 年)才支持真正的异步 IO(io\_uring),从而间接影响了JAVA的发展,Java的 AIO直到 2011 年Java 7才正式发布,而其前一代 NIO已发展近 10 年。 而Windows的IOCP在Windows NT 4.0 (1996年)就登上了历史舞台,加上C#起步较晚,没有历史包袱,所以对AIO支持力度更大,尤其是2012年发布了async/await异步模型后,解决了回调地狱,实现了1+1>3的效果。 **2. JAVA的路径依赖** NIO生态过于强大,尤其是以Netty/Redis为首的经典实现,实在是太香了! **3. 理论优势并未转换为实际收益** AIO的性能在特定场景(如超大规模文件读写、长连接低活跃)下可能优于NIO,但在互联网场景中,NIO的足够高效,比如HTTP请求,AIO的异步回调优势相对轮询并不明显。 | 维度 | Java AIO未普及的原因 | C# AIO普及的原因 | | --- | --- | --- | | **历史发展** | NIO早于AIO 9年推出,生态成熟;AIO定位模糊,未解决NIO的核心痛点(如编程复杂度) | AIO与`async/await`同步推出,解决了异步编程的“回调地狱”,成为高并发编程的默认选择 | | **跨平台** | 需适配多系统异步机制(如Linux的`epoll`、macOS的`kqueue`),实际性能提升有限 | 早期绑定Windows IOCP,性能稳定;跨平台后对AIO需求不迫切 | | **生态** | Netty等NIO框架统治市场,切换AIO成本高 | 缺乏NIO统治级框架,AIO通过`async/await`成为原生选择 | | **开发者习惯** | NIO代码虽复杂,但通过框架封装已足够易用;AIO回调模式学习成本更高 | `async/await`语法糖让异步代码接近同步,开发者更易接受 | | **性能场景** | 大多数场景下NIO已足够高效,AIO的优势未显著体现 | Windows IOCP场景下AIO性能优势明显,且覆盖主流企业级需求 | > 说人话就是,Netty太香了,完全没动力切换成AIO,顺带吐槽C#中没有类似的框架。dotnetty不算,已经停止更新了。