找回密码
 立即注册
首页 业界区 业界 C#网络编程(六)----Socket编程模型

C#网络编程(六)----Socket编程模型

万妙音 4 天前
简介

Socket(套接字)是计算机网络中的一套编程接口,是网络编程的核心,它将复杂的网络协议封装为简单的API,是应用层(HTTP)与传输层(TCP)之间的桥梁。
应用程序通过调用Socket API,比如connect、send、recv,无需处理IP包封装,路由选择等复杂网络操作,屏蔽底层细节将网络通信简化为建立连接-数据接收-数据发送-连接断开,降低了开发复杂度。

FD&Handle


  • FD
    文件描述符,在linux系统中,一切皆文件,它是内核为了管理已打开的文件,而给每个进程维护的一个文件描述符表,而FD就是一个文件的索引。
  • Handle
    而在windows平台下,这个概念被称为Handle(句柄),都为应用程序提供了一种统一的方式来访问和操作资源,隐藏了底层资源管理的复杂性。
FD主要用于标识文件、套接字、管道等输入输出资源;而Handle的应用范围更广,除了文件和网络资源外,还可以用于标识窗口、进程、线程、设备对象等各种系统资源。
Socket 网络模型

BIO,Blocking I/O

BIO 是最传统的 I/O 模型,其核心特征是一个连接一个线程,线程在读取/写入时会阻塞,直到I/O操作完成。
  1.         private static Socket _server;
  2.         private static byte[] _buffer = new byte[1024 * 4];
  3.         static void Main(string[] args)
  4.         {
  5.             _server=new Socket(AddressFamily.InterNetwork,SocketType.Stream, ProtocolType.Tcp);
  6.             _server.Bind(new IPEndPoint(IPAddress.Any, 6666));
  7.             _server.Listen();
  8.             
  9.             while (true)
  10.             {
  11.                 //BIO核心,线程阻塞,等待客户端连接
  12.                 var client = _server.Accept();
  13.                 Console.WriteLine($"Client {client.RemoteEndPoint} connect. ");
  14.                 //BIO核心,线程阻塞,等待客户端发送消息
  15.                 var messageCount = client.Receive(_buffer);
  16.                 var message = Encoding.UTF8.GetString(_buffer, 0, messageCount);
  17.                 Console.WriteLine($"Client {client.RemoteEndPoint} Say:{message}");
  18.             }
  19.         }
复制代码
从代码中可以看出,有两个地方阻塞,一是Accept(),二是Receive(),如果客户端一直不发送数据,那么线程会一直阻塞在Receive()上,也不会接受其它客户端的连接。
2.png

C10K问题

有聪明的小伙伴会想到,我可以利用多线程来处理Receive(),这样就服务端就可以接受其它客户端的连接了。
  1.     internal class Program
  2.     {
  3.         private static Socket _server;
  4.         private static byte[] _buffer = new byte[1024 * 4];
  5.         static void Main(string[] args)
  6.         {
  7.             _server=new Socket(AddressFamily.InterNetwork,SocketType.Stream, ProtocolType.Tcp);
  8.             _server.Bind(new IPEndPoint(IPAddress.Any, 6666));
  9.             _server.Listen();
  10.             
  11.             while (true)
  12.             {
  13.                 //BIO核心,线程阻塞,等待客户端连接
  14.                 var client = _server.Accept();
  15.                 Console.WriteLine($"Client {client.RemoteEndPoint} connect. ");
  16.                 //多线程读取客户端数据,避免主线程阻塞
  17.                 Task.Run(() => HandleClient(client));
  18.             }
  19.         }
  20.         static void HandleClient(Socket client)
  21.         {
  22.             while (true)
  23.             {
  24.                 //BIO核心,线程阻塞,等待客户端发送消息
  25.                 var messageCount = client.Receive(_buffer);
  26.                 var message = Encoding.UTF8.GetString(_buffer, 0, messageCount);
  27.                 Console.WriteLine($"Client {client.RemoteEndPoint} Say:{message}");
  28.             }
  29.         }
  30.     }
复制代码
当给客户端建立好连接后,会启用一个新的线程来单独处理Receive(),避免了主线程阻塞。
但有一个严重的缺陷,就是当一万个客户端同时连接,服务端要创建一万个线程来接。一万个线程带来的CPU上下文切换与内存成本,非常容易会拖垮服务器。这就是C10K问题来由来。
因此,BIO的痛点在于:

  • 高并发下资源耗尽
    当连接数激增时,线程数量呈线性增长(如 10000 个连接对应 10000 个线程),导致内存占用过高、上下文切换频繁,系统性能急剧下降。
  • 阻塞导致效率低下
    线程在等待 IO 时无法做其他事情,CPU 利用率低。
NIO,Non-Blocking I/O

为了解决此问题,需要跪舔操作系统,为用户态程序提供一个真正非阻塞的Accept/Receive的函数。
该函数的效果应该是,当没有新连接/新数据到达时,不阻塞线程。而是返回一个特殊标识,来告诉线程没有活干。
Java 1.4 引入 NIO,C# 通过Begin/End异步方法或SocketAsyncEventArgs实现类似逻辑。
  1.     internal class Program
  2.     {
  3.         private static Socket _server;
  4.         private static byte[] _buffer = new byte[1024 * 4];
  5.         //所有客户端的连接
  6.         private static readonly List<Socket> _clients = new List<Socket>();
  7.         static void Main(string[] args)
  8.         {
  9.             _server=new Socket(AddressFamily.InterNetwork,SocketType.Stream, ProtocolType.Tcp);
  10.             _server.Bind(new IPEndPoint(IPAddress.Any, 6666));
  11.             _server.Listen();
  12.             //NIO核心,设为非阻塞模式
  13.             _server.Blocking = false;
  14.             while (true)
  15.             {
  16.                 try
  17.                 {
  18.                     var client = _server.Accept();
  19.                     _clients.Add(client);
  20.                     Console.WriteLine($"Client {client.RemoteEndPoint} connect. ");
  21.                 }
  22.                 catch (SocketException ex) when(ex.SocketErrorCode==SocketError.WouldBlock)
  23.                 {
  24.                     //没有新连接时,调用Accept触发WouldBlock异常,无视即可。
  25.                 }
  26.                                 //一个线程同时管理Accept与Receive,已经有了多路复用的意思。
  27.                 HandleClient();
  28.             }
  29.         }
  30.         static void HandleClient()
  31.         {
  32.                         //一个一个遍历,寻找可用的客户端,
  33.             foreach (var client in _clients.ToList())
  34.             {
  35.                 try
  36.                 {
  37.                     //NIO核心,非阻塞读取数据,无数据时立刻返回
  38.                     var messageCount = client.Receive(_buffer, SocketFlags.None);
  39.                     var message = Encoding.UTF8.GetString(_buffer, 0, messageCount);
  40.                     Console.WriteLine($"Client {client.RemoteEndPoint} Say:{message}");
  41.                 }
  42.                 catch (SocketException ex) when (ex.SocketErrorCode == SocketError.WouldBlock)
  43.                 {
  44.                     //没有新数据读取时,调用Receive触发WouldBlock异常,无视即可。
  45.                 }
  46.             }
  47.         }
  48.     }
复制代码
通过NIO,我们可以非常惊喜的发现。我们仅用了一个线程就完成对客户端的连接与监听,相对BIO有了质的变化。
当数据未就绪时(内核缓冲区无数据),非阻塞模式下的Accept/Receive会立即返回WouldBlock异常(或-1);当数据就绪时,调用会立即返回读取的字节数(>0),不会阻塞线程。数据从内核缓冲区到用户缓冲区的拷贝由 CPU 同步完成,属于正常 IO 操作流程,不涉及线程阻塞
3.png

尽管NIO已经是JAVA世界的绝对主流,但依旧存在几个痛点:

  • 轮询开销
    如果事件比较少,轮询会产生大量空转,CPU资源被浪费。
  • 需要手动处理细节
    比如手动编写捕获when (ex.SocketErrorCode == SocketError.WouldBlock)来识别状态,
    需要手动处理TPC粘包,以及各种异常处理。
AIO,Asynchronous  I/O

AIO作为大魔王与终极优化,实现了真正的异步操作,当发起IO请求后,内核完全接管IO处理,完成后通过回调或者事件来通知程序,开发者无需关心缓冲区管理、事件状态跟踪或轮询开销。
Java 7 引入 NIO.2(AIO),C# 通过IOCP+Async来实现
  1.     internal class Program
  2.     {
  3.         private static Socket _server;
  4.         private static Memory<byte> _buffer = new byte[1024 * 4];
  5.         //所有客户端的连接
  6.         private static readonly List<Socket> _clients = new List<Socket>();
  7.         static async Task Main(string[] args)
  8.         {
  9.             _server=new Socket(AddressFamily.InterNetwork,SocketType.Stream, ProtocolType.Tcp);
  10.             _server.Bind(new IPEndPoint(IPAddress.Any, 6666));
  11.             _server.Listen();
  12.             while (true)
  13.             {
  14.                 //异步等待连接,线程不阻塞
  15.                 var client = await _server.AcceptAsync();
  16.                 //不阻塞主线程,由线程池调度
  17.                 HandleClientAsync(client);
  18.             }
  19.             
  20.         }
  21.         private static async Task HandleClientAsync(Socket client)
  22.         {
  23.             //异步读取数据,由操作系统完成IO后唤醒
  24.             var messageCount = await client.ReceiveAsync(_buffer);
  25.             var message = Encoding.UTF8.GetString(_buffer.ToArray(), 0, messageCount);
  26.             Console.WriteLine($"Client {client.RemoteEndPoint} Say:{message}");
  27.         }
  28.     }
复制代码
4.png

Linux/Windows对模型的支持


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都是一次用户态到内核态的切换。
那么,如果让操作系统告诉我们,哪些连接是可用的,我们就避免了在用户态遍历,从而提高性能。
6.gif
  1.         /// <summary>
  2.         /// 伪代码
  3.         /// </summary>
  4.         static void HandleClientSelect()
  5.         {
  6.             var clients = _clients.ToList();
  7.             //自己不遍历,交给内核态去遍历.
  8.             //这里会有一次list copy到内核态的过程,如果list量很大,开销也不小.
  9.             var readyClients= Socket.Select(clients);
  10.             //内核会帮你标记好哪些client已经就绪
  11.             foreach (var client in readyClients)
  12.             {
  13.                 //用户态依旧需要遍历一遍,但避免无意义的系统调用,用户态到内核态的切换.只有真正就绪的client才处理
  14.                 if (client.IsReady)
  15.                 {
  16.                     var messageCount = client.Receive(_buffer, SocketFlags.None);
  17.                     var message = Encoding.UTF8.GetString(_buffer, 0, messageCount);
  18.                     Console.WriteLine($"Client {client.RemoteEndPoint} Say:{message}");
  19.                 }
  20.                 else
  21.                 {
  22.                     break;
  23.                 }
  24.             }
  25.         }
复制代码
通过监听一组文件描述符(File Descriptor, FD)的可读、可写或异常状态,当其中任意状态满足时,内核返回就绪的 FD 集合。用户需遍历所有 FD 判断具体就绪的 I/O 操作。
select模型受限于系统默认值,最大只能处理1024个连接。poll模型通过结构体数组替代select位图的方式,避免了数量限制,其它无区别。
epoll

作为NIO的终极解决方案,它解决了什么问题?

  • 调用select需要传递整个List
    var readyClients= Socket.Select(clients);
    如果list中有10W+,那么这个copy的成本会非常高
  • select依旧是线性遍历
    在内核层面依旧是遍历整个list,寻找可用的client,所以时间复杂度不变O(N),只是减少了从用户态切换到内核态的次数而已
  • 仅仅对ready做标记,并不减少返回量
    select仅仅返回就绪的数量,具体是哪个就绪,还要自己遍历一遍。
所以epoll模型主要主要针对这三点,做出了如下优化:

  • 通过mmap,zero copy,减少数据拷贝
  • 不再通过轮询方式,而是通过异步事件通知唤醒,内部使用红黑树来管理fd/handle
  • 唤醒后,仅仅返回有变化的fd/handle,用户无需遍历整个list
基于事件驱动(Event-Driven)机制,内核维护一个 FD 列表,通过epoll_ctl添加 / 删除 FD 监控,epoll_wait阻塞等待就绪事件。就绪的 FD 通过事件列表返回,用户仅需处理就绪事件对应的 FD。
7.gif

点击查看代码[code]#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
您需要登录后才可以回帖 登录 | 立即注册