之前的一篇介绍IO 模型的文章IO 模型知多少 | 理论篇
比较偏理论,很多同学反应不是很好理解。这一篇咱们换一个角度,从代码角度来分析一下。
开始之前,我们先来梳理一下,需要提前了解的几个概念:
socket: 直译为“插座”,在计算机通信领域,socket 被翻译为“套接字”,它是计算机之间进行通信的一种约定或一种方式。通过 socket 这种约定,一台计算机可以接收其他计算机的数据,也可以向其他计算机发送数据。我们把插头插到插座上就能从电网获得电力供应,同样,应用程序为了与远程计算机进行数据传输,需要连接到因特网,而 socket 就是用来连接到因特网的工具。
另外还需要知道的是,socket 编程的基本流程。
先回顾下概念:阻塞IO是指,应用进程中线程在发起IO调用后至内核执行IO操作返回结果之前,若发起系统调用的线程一直处于等待状态,则此次IO操作为阻塞IO。
public static void Start() { //1. 创建Tcp Socket对象 var serverSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); var ipEndpoint = new IPEndPoint(IPAddress.Loopback, 5001); //2. 绑定Ip端口 serverSocket.Bind(ipEndpoint); //3. 开启监听,指定最大连接数 serverSocket.Listen(10); Console.WriteLine($"服务端已启动({ipEndpoint})-等待连接..."); while (true) { //4. 等待客户端连接 var clientSocket = serverSocket.Accept();//阻塞 Console.WriteLine($"{clientSocket.RemoteEndPoint}-已连接"); Span<byte> buffer = new Span<byte>(new byte[512]); Console.WriteLine($"{clientSocket.RemoteEndPoint}-开始接收数据..."); int readLength = clientSocket.Receive(buffer);//阻塞 var msg = Encoding.UTF8.GetString(buffer.ToArray(), 0, readLength); Console.WriteLine($"{clientSocket.RemoteEndPoint}-接收数据:{msg}"); var sendBuffer = Encoding.UTF8.GetBytes($"received:{msg}"); clientSocket.Send(sendBuffer); } }代码很简单,直接看注释就OK了,运行结果如上图所示,但有几个问题点需要着重说明下:
等待连接处serverSocket.Accept(),线程阻塞!
接收数据处clientSocket.Receive(buffer),线程阻塞!
会导致什么问题呢:
只有一次数据读取完成后,才可以接受下一个连接请求
一个连接,只能接收一次数据
同步非阻塞IO看完,你可能会说,这两个问题很好解决啊,创建一个新线程去接收数据就是了。于是就有了下面的代码改进。
public static void Start2() { //1. 创建Tcp Socket对象 var serverSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); var ipEndpoint = new IPEndPoint(IPAddress.Loopback, 5001); //2. 绑定Ip端口 serverSocket.Bind(ipEndpoint); //3. 开启监听,指定最大连接数 serverSocket.Listen(10); Console.WriteLine($"服务端已启动({ipEndpoint})-等待连接..."); while (true) { //4. 等待客户端连接 var clientSocket = serverSocket.Accept();//阻塞 Task.Run(() => ReceiveData(clientSocket)); } } private static void ReceiveData(Socket clientSocket) { Console.WriteLine($"{clientSocket.RemoteEndPoint}-已连接"); Span<byte> buffer = new Span<byte>(new byte[512]); while (true) { if (clientSocket.Available == 0) continue; Console.WriteLine($"{clientSocket.RemoteEndPoint}-开始接收数据..."); int readLength = clientSocket.Receive(buffer);//阻塞 var msg = Encoding.UTF8.GetString(buffer.ToArray(), 0, readLength); Console.WriteLine($"{clientSocket.RemoteEndPoint}-接收数据:{msg}"); var sendBuffer = Encoding.UTF8.GetBytes($"received:{msg}"); clientSocket.Send(sendBuffer); } }是的,多线程解决了上述的问题,但如果你观察以上动图后,你应该能发现个问题:才建立4个客户端连接,CPU的占用率就开始直线上升了。
而这个问题的本质就是,服务端的IO模型为阻塞IO模型,为了解决阻塞导致的问题,采用重复轮询,导致无效的系统调用,从而导致CPU持续走高。
IO多路复用既然知道原因所在,咱们就来予以改造。适用异步方式来处理连接、接收和发送数据。
public static class NioServer { private static ManualResetEvent _acceptEvent = new ManualResetEvent(true); private static ManualResetEvent _readEvent = new ManualResetEvent(true); public static void Start() { //1. 创建Tcp Socket对象 var serverSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); // serverSocket.Blocking = false;//设置为非阻塞 var ipEndpoint = new IPEndPoint(IPAddress.Loopback, 5001); //2. 绑定Ip端口 serverSocket.Bind(ipEndpoint); //3. 开启监听,指定最大连接数 serverSocket.Listen(10); Console.WriteLine($"服务端已启动({ipEndpoint})-等待连接..."); while (true) { _acceptEvent.Reset();//重置信号量 serverSocket.BeginAccept(OnClientConnected, serverSocket); _acceptEvent.WaitOne();//阻塞 } } private static void OnClientConnected(IAsyncResult ar) { _acceptEvent.Set();//当有客户端连接进来后,则释放信号量 var serverSocket = ar.AsyncState as Socket; Debug.Assert(serverSocket != null, nameof(serverSocket) + " != null"); var clientSocket = serverSocket.EndAccept(ar); Console.WriteLine($"{clientSocket.RemoteEndPoint}-已连接"); while (true) { _readEvent.Reset();//重置信号量 var stateObj = new StateObject { ClientSocket = clientSocket }; clientSocket.BeginReceive(stateObj.Buffer, 0, stateObj.Buffer.Length, SocketFlags.None, OnMessageReceived, stateObj); _readEvent.WaitOne();//阻塞等待 } } private static void OnMessageReceived(IAsyncResult ar) { var state = ar.AsyncState as StateObject; Debug.Assert(state != null, nameof(state) + " != null"); var receiveLength = state.ClientSocket.EndReceive(ar); if (receiveLength > 0) { var msg = Encoding.UTF8.GetString(state.Buffer, 0, receiveLength); Console.WriteLine($"{state.ClientSocket.RemoteEndPoint}-接收数据:{msg}"); var sendBuffer = Encoding.UTF8.GetBytes($"received:{msg}"); state.ClientSocket.BeginSend(sendBuffer, 0, sendBuffer.Length, SocketFlags.None, SendMessage, state.ClientSocket); } } private static void SendMessage(IAsyncResult ar) { var clientSocket = ar.AsyncState as Socket; Debug.Assert(clientSocket != null, nameof(clientSocket) + " != null"); clientSocket.EndSend(ar); _readEvent.Set(); //发送完毕后,释放信号量 } } public class StateObject { // Client socket. public Socket ClientSocket = null; // Size of receive buffer. public const int BufferSize = 1024; // Receive buffer. public byte[] Buffer = new byte[BufferSize]; }首先来看运行结果,从下图可以看到,除了建立连接时CPU出现抖动外,在消息接收和发送阶段,CPU占有率趋于平缓,且占用率低。
分析代码后我们发现:
CPU使用率是下来了,但代码复杂度上升了。
使用异步接口处理客户端连接:BeginAccept和EndAccept
使用异步接口接收数据:BeginReceive和EndReceive
使用异步接口发送数据:BeginSend和EndSend
使用ManualResetEvent进行线程同步,避免线程空转
那你可能好奇,以上模型是何种IO多路复用模型呢?
好问题,我们来一探究竟。