Socket编程概念

Socket编程概念一 什么是 SOCKETsocket 的英文原义是 孔 或 插座

大家好,欢迎来到IT知识分享网。

什么是SOCKET

Socket编程概念

套接字分类

为了满足不同程序对通信质量和性能的要求,一般的网络系统都提供了以下3种不同类型的套接字,以供用户在设计程序时根据不同需要来选择:

流式套接字(SOCK_STREAM):提供了一种可靠的、面向连接的双向数据传输服务。实现了数据无差错,无重复的发送,内设流量控制,被传输的数据被看做无记录边界的字节流。在TCP/IP协议簇中,使用TCP实现字节流的传输,当用户要发送大批量数据,或对数据传输的可靠性有较高要求时使用流式套接字。

数据报套接字(SOCK_DGRAM):提供了一种无连接、不可靠的双向数据传输服务。数据以独立的包形式被发送,并且保留了记录边界,不提供可靠性保证。数据在传输过程中可能会丢失或重复,并且不能保证在接收端数据按发送顺序接收。在TCP/IP协议簇中,使用UDP实现数据报套接字。

原始套接字(SOCK_RAW):该套接字允许对较低层协议(如IP或ICMP)进行直接访问。一般用于对TCP/IP核心协议的网络编程。  几乎没用过这个。

Socket编程概念

SOCKET相关概念

端口

在Internet上有很多这样的主机,这些主机一般运行了多个服务软件,同时提供几种服务。每种服务都打开一个Socket,并绑定到一个端口上,不同的端口对应于不同的服务(应用程序),因此,在网络协议中使用端口号识别主机上不同的进程
例如:http使用80端口,FTP使用21端口。

电脑运行的系统程序,其实就像一个闭合的圆圈,但是电脑是为人服务的,他需要接受一些指令,并且要按照指令调整系统功能来工作,于是系统程序设计者,就把这个圆圈截成好多段,这些线段接口就叫端口(通俗讲是断口,就是中断),系统运行到这些端口时,一看端口是否打开或关闭,如果关闭,就是绳子接通了,系统往下运行,如果端口是打开的,系统就得到命令,有外部数据输入,接受外部数据并执行.

软件端口   缓冲区。

协议端口

端口类型

端口作用

认识网卡

网络通信三要素:

  • IP地址(网络上主机设备的唯一标识)
  • 端口号(定位程序)
  •      有效端口:0~65535,其中0~1024由系统使用,开发中一般使用1024以上端口.
  • 传输协议(用什么样的方式进行交互)
  •      常见协议:TCP(面向连接,提供可靠的服务),UDP(无连接,传输速度快)
IP地址
协议

 TCP:TCP是一种面向连接的、可靠的,基于字节流的传输层通信协议。为两台主机提供高可靠性的数据通信服务。它可以将源主机的数据无差错地传输到目标主机。当有数据要发送时,对应用进程送来的数据进行分片,以适合于在网络层中传输;当接收到网络层传来的分组时,它要对收到的分组进行确认,还要对丢失的分组设置超时重发等。为此TCP需要增加额外的许多开销,以便在数据传输过程中进行一些必要的控制,确保数据的可靠传输。因此,TCP传输的效率比较低。

Socket编程概念

Socket编程概念

Socket编程概念

Socket编程概念

Socket编程概念

Socket编程概念

Socket编程概念

TCP的工作过程

TCP是面向连接的协议,TCP协议通过三个报文段完成类似电话呼叫的连接建立过程,这个过程称为三次握手,如图所示:

Socket编程概念

第一次握手:建立连接时,客户端发送SYN包(SEQ=x)到服务器,并进入SYN_SEND状态,等待服务器确认。

第二次握手:服务器收到SYN包,必须确认客户的SYN(ACK=x+1),同时自己也发送一个SYN包(SEQ=y),即SYN+ACK包,此时服务器进入SYN_RECV状态。

第三次握手:客户端收到服务器的SYN+ACK包,向服务器发送确认包ACK(ACK=y+1),此包发送完毕,客户端和服务器进入Established状态,完成三次握手。

传输数据
连接的终止

建立一个连接需要三次握手,而终止一个连接要经过四次握手,这是由TCP的半关闭(half-close)造成的。具体过程如图所示:

Socket编程概念

TCP的主要特点

Socket编程概念

同步与异步

 UDP

UDP是一种简单的、面向数据报的无连接的协议,提供的是不一定可靠的传输服务。所谓“无连接”是指在正式通信前不必与对方先建立连接,不管对方状态如何都直接发送过去。这与发手机短信非常相似,只要知道对方的手机号就可以了,不要考虑对方手机处于什么状态。UDP虽然不能保证数据传输的可靠性,但数据传输的效率较高。

UDP与TCP的区别
UDP的优势

Socket编程概念

Socket与TCP/IP协议的关系

Socket编程概念

 二是应用程序可以通过它们来修改内核中各层协议的某些头部信息或其他数据结构,从而精细地控制底层通信的行为。

Socket编程概念

socket一般应用模式:

Socket编程概念

Socket编程概念

Socket编程概念

 Socket编程概念

端口

服务器端需要一直监听本地端口,看是否有客户端的连接请求,通信过程这里再简要说一下:

  1. 使用socket()创建用于监听的文件描述符listen_fd
  2. 使用bind()将上一步的文件描述符listen_fd与本地的IP和端口绑定
  3. 使用listen()监听listen_fd
  4. 使用accept()建立连接,返回用于通信的文件描述符correspond_fd
  5. 开始收发数据
  6. 关闭socket

示例程序1:

Socket编程概念

Socket编程概念

 using System; 2 using System.Collections.Generic; 3 using System.ComponentModel; 4 using System.Data; 5 using System.Drawing; 6 using System.Linq; 7 using System.Net; 8 using System.Net.Sockets; 9 using System.Text; 10 using System.Threading.Tasks; 11 using System.Windows.Forms; 12 using System.Threading; 13 using System.IO; 14 15 namespace SocketServer 16 { 17 public partial class FrmServer : Form 18 { 19 public FrmServer() 20 { 21 InitializeComponent(); 22 } 23 24 //定义回调:解决跨线程访问问题 25 private delegate void SetTextValueCallBack(string strValue); 26 //定义接收客户端发送消息的回调 27 private delegate void ReceiveMsgCallBack(string strReceive); 28 //声明回调 29 private SetTextValueCallBack setCallBack; 30 //声明 31 private ReceiveMsgCallBack receiveCallBack; 32 //定义回调:给ComboBox控件添加元素 33 private delegate void SetCmbCallBack(string strItem); 34 //声明 35 private SetCmbCallBack setCmbCallBack; 36 //定义发送文件的回调 37 private delegate void SendFileCallBack(byte[] bf); 38 //声明 39 private SendFileCallBack sendCallBack; 40 41 //用于通信的Socket 42 Socket socketSend; 43 //用于监听的SOCKET 44 Socket socketWatch; 45 46 //将远程连接的客户端的IP地址和Socket存入集合中 47 Dictionary<string, Socket> dicSocket = new Dictionary<string, Socket>(); 48 49 //创建监听连接的线程 50 Thread AcceptSocketThread; 51 //接收客户端发送消息的线程 52 Thread threadReceive; 53 54 /// <summary> 55 /// 开始监听 56 /// </summary> 57 /// <param name="sender"></param> 58 /// <param name="e"></param> 59 private void btn_Start_Click(object sender, EventArgs e) 60 { 61 //当点击开始监听的时候 在服务器端创建一个负责监听IP地址和端口号的Socket 62 socketWatch = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); 63 //获取ip地址 64 IPAddress ip=IPAddress.Parse(this.txt_IP.Text.Trim()); 65 //创建端口号 66 IPEndPoint point=new IPEndPoint(ip,Convert.ToInt32(this.txt_Port.Text.Trim())); 67 //绑定IP地址和端口号 68 socketWatch.Bind(point); 69 this.txt_Log.AppendText("监听成功"+" \r \n"); 70 //开始监听:设置最大可以同时连接多少个请求 71 socketWatch.Listen(10); 72 73 //实例化回调 74 setCallBack = new SetTextValueCallBack(SetTextValue); 75 receiveCallBack = new ReceiveMsgCallBack(ReceiveMsg); 76 setCmbCallBack = new SetCmbCallBack(AddCmbItem); 77 sendCallBack = new SendFileCallBack(SendFile); 78 79 //创建线程 80 AcceptSocketThread = new Thread(new ParameterizedThreadStart(StartListen)); 81 AcceptSocketThread.IsBackground = true; 82 AcceptSocketThread.Start(socketWatch); 83 } 84 85 /// <summary> 86 /// 等待客户端的连接,并且创建与之通信用的Socket 87 /// </summary> 88 /// <param name="obj"></param> 89 private void StartListen(object obj) 90 { 91 Socket socketWatch = obj as Socket; 92 while (true) 93 { 94 //等待客户端的连接,并且创建一个用于通信的Socket 95 socketSend = socketWatch.Accept(); 96 //获取远程主机的ip地址和端口号 97 string strIp=socketSend.RemoteEndPoint.ToString(); 98 dicSocket.Add(strIp, socketSend); 99 this.cmb_Socket.Invoke(setCmbCallBack, strIp); 100 string strMsg = "远程主机:" + socketSend.RemoteEndPoint + "连接成功"; 101 //使用回调 102 txt_Log.Invoke(setCallBack, strMsg); 103 104 //定义接收客户端消息的线程 105 Thread threadReceive = new Thread(new ParameterizedThreadStart(Receive)); 106 threadReceive.IsBackground = true; 107 threadReceive.Start(socketSend); 108 109 } 110 } 111 112 113 114 /// <summary> 115 /// 服务器端不停的接收客户端发送的消息 116 /// </summary> 117 /// <param name="obj"></param> 118 private void Receive(object obj) 119 { 120 Socket socketSend = obj as Socket; 121 while (true) 122 { 123 //客户端连接成功后,服务器接收客户端发送的消息 124 byte[] buffer = new byte[2048]; 125 //实际接收到的有效字节数 126 int count = socketSend.Receive(buffer); 127 if (count == 0)//count 表示客户端关闭,要退出循环 128 { 129 break; 130 } 131 else 132 { 133 string str = Encoding.Default.GetString(buffer, 0, count); 134 string strReceiveMsg = "接收:" + socketSend.RemoteEndPoint + "发送的消息:" + str; 135 txt_Log.Invoke(receiveCallBack, strReceiveMsg); 136 } 137 } 138 } 139 140 /// <summary> 141 /// 回调委托需要执行的方法 142 /// </summary> 143 /// <param name="strValue"></param> 144 private void SetTextValue(string strValue) 145 { 146 this.txt_Log.AppendText(strValue + " \r \n"); 147 } 148 149 150 private void ReceiveMsg(string strMsg) 151 { 152 this.txt_Log.AppendText(strMsg + " \r \n"); 153 } 154 155 private void AddCmbItem(string strItem) 156 { 157 this.cmb_Socket.Items.Add(strItem); 158 } 159 160 /// <summary> 161 /// 服务器给客户端发送消息 162 /// </summary> 163 /// <param name="sender"></param> 164 /// <param name="e"></param> 165 private void btn_Send_Click(object sender, EventArgs e) 166 { 167 try 168 { 169 string strMsg = this.txt_Msg.Text.Trim(); 170 byte[] buffer = Encoding.Default.GetBytes(strMsg); 171 List<byte> list = new List<byte>(); 172 list.Add(0); 173 list.AddRange(buffer); 174 //将泛型集合转换为数组 175 byte[] newBuffer = list.ToArray(); 176 //获得用户选择的IP地址 177 string ip = this.cmb_Socket.SelectedItem.ToString(); 178 dicSocket[ip].Send(newBuffer); 179 } 180 catch (Exception ex) 181 { 182 MessageBox.Show("给客户端发送消息出错:"+ex.Message); 183 } 184 //socketSend.Send(buffer); 185 } 186 187 /// <summary> 188 /// 选择要发送的文件 189 /// </summary> 190 /// <param name="sender"></param> 191 /// <param name="e"></param> 192 private void btn_Select_Click(object sender, EventArgs e) 193 { 194 OpenFileDialog dia = new OpenFileDialog(); 195 //设置初始目录 196 dia.InitialDirectory = @""; 197 dia.Title = "请选择要发送的文件"; 198 //过滤文件类型 199 dia.Filter = "所有文件|*.*"; 200 dia.ShowDialog(); 201 //将选择的文件的全路径赋值给文本框 202 this.txt_FilePath.Text = dia.FileName; 203 } 204 205 /// <summary> 206 /// 发送文件 207 /// </summary> 208 /// <param name="sender"></param> 209 /// <param name="e"></param> 210 private void btn_SendFile_Click(object sender, EventArgs e) 211 { 212 List<byte> list = new List<byte>(); 213 //获取要发送的文件的路径 214 string strPath = this.txt_FilePath.Text.Trim(); 215 using (FileStream sw = new FileStream(strPath,FileMode.Open,FileAccess.Read)) 216 { 217 byte[] buffer = new byte[2048]; 218 int r = sw.Read(buffer, 0, buffer.Length); 219 list.Add(1); 220 list.AddRange(buffer); 221 222 byte[] newBuffer = list.ToArray(); 223 //发送 224 //dicSocket[cmb_Socket.SelectedItem.ToString()].Send(newBuffer, 0, r+1, SocketFlags.None); 225 btn_SendFile.Invoke(sendCallBack, newBuffer); 226 227 228 } 229 230 } 231 232 private void SendFile(byte[] sendBuffer) 233 { 234 235 try 236 { 237 dicSocket[cmb_Socket.SelectedItem.ToString()].Send(sendBuffer, SocketFlags.None); 238 } 239 catch (Exception ex) 240 { 241 MessageBox.Show("发送文件出错:"+ex.Message); 242 } 243 } 244 245 private void btn_Shock_Click(object sender, EventArgs e) 246 { 247 byte[] buffer = new byte[1] { 2}; 248 dicSocket[cmb_Socket.SelectedItem.ToString()].Send(buffer); 249 } 250 251 /// <summary> 252 /// 停止监听 253 /// </summary> 254 /// <param name="sender"></param> 255 /// <param name="e"></param> 256 private void btn_StopListen_Click(object sender, EventArgs e) 257 { 258 socketWatch.Close(); 259 socketSend.Close(); 260 //终止线程 261 AcceptSocketThread.Abort(); 262 threadReceive.Abort(); 263 } 264 } 265 }

Socket编程概念

1 using System; 2 using System.Collections.Generic; 3 using System.ComponentModel; 4 using System.Data; 5 using System.Drawing; 6 using System.Linq; 7 using System.Text; 8 using System.Threading.Tasks; 9 using System.Windows.Forms; 10 using System.Net.Sockets; 11 using System.Net; 12 using System.Threading; 13 using System.IO; 14 15 namespace SocketClient 16 { 17 public partial class FrmClient : Form 18 { 19 public FrmClient() 20 { 21 InitializeComponent(); 22 } 23 24 //定义回调 25 private delegate void SetTextCallBack(string strValue); 26 //声明 27 private SetTextCallBack setCallBack; 28 29 //定义接收服务端发送消息的回调 30 private delegate void ReceiveMsgCallBack(string strMsg); 31 //声明 32 private ReceiveMsgCallBack receiveCallBack; 33 34 //创建连接的Socket 35 Socket socketSend; 36 //创建接收客户端发送消息的线程 37 Thread threadReceive; 38 39 /// <summary> 40 /// 连接 41 /// </summary> 42 /// <param name="sender"></param> 43 /// <param name="e"></param> 44 private void btn_Connect_Click(object sender, EventArgs e) 45 { 46 try 47 { 48 socketSend = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); 49 IPAddress ip = IPAddress.Parse(this.txt_IP.Text.Trim()); 50 socketSend.Connect(ip, Convert.ToInt32(this.txt_Port.Text.Trim())); 51 //实例化回调 52 setCallBack = new SetTextCallBack(SetValue); 53 receiveCallBack = new ReceiveMsgCallBack(SetValue); 54 this.txt_Log.Invoke(setCallBack, "连接成功"); 55 56 //开启一个新的线程不停的接收服务器发送消息的线程 57 threadReceive = new Thread(new ThreadStart(Receive)); 58 //设置为后台线程 59 threadReceive.IsBackground = true; 60 threadReceive.Start(); 61 } 62 catch (Exception ex) 63 { 64 MessageBox.Show("连接服务端出错:" + ex.ToString()); 65 } 66 } 67 68 /// <summary> 69 /// 接口服务器发送的消息 70 /// </summary> 71 private void Receive() 72 { 73 try 74 { 75 while (true) 76 { 77 byte[] buffer = new byte[2048]; 78 //实际接收到的字节数 79 int r = socketSend.Receive(buffer); 80 if (r == 0) 81 { 82 break; 83 } 84 else 85 { 86 //判断发送的数据的类型 87 if (buffer[0] == 0)//表示发送的是文字消息 88 { 89 string str = Encoding.Default.GetString(buffer, 1, r - 1); 90 this.txt_Log.Invoke(receiveCallBack, "接收远程服务器:" + socketSend.RemoteEndPoint + "发送的消息:" + str); 91 } 92 //表示发送的是文件 93 if (buffer[0] == 1) 94 { 95 SaveFileDialog sfd = new SaveFileDialog(); 96 sfd.InitialDirectory = @""; 97 sfd.Title = "请选择要保存的文件"; 98 sfd.Filter = "所有文件|*.*"; 99 sfd.ShowDialog(this); 100 101 string strPath = sfd.FileName; 102 using (FileStream fsWrite = new FileStream(strPath, FileMode.OpenOrCreate, FileAccess.Write)) 103 { 104 fsWrite.Write(buffer, 1, r - 1); 105 } 106 107 MessageBox.Show("保存文件成功"); 108 } 109 } 110 111 112 } 113 } 114 catch (Exception ex) 115 { 116 MessageBox.Show("接收服务端发送的消息出错:" + ex.ToString()); 117 } 118 } 119 120 121 private void SetValue(string strValue) 122 { 123 this.txt_Log.AppendText(strValue + "\r \n"); 124 } 125 126 /// <summary> 127 /// 客户端给服务器发送消息 128 /// </summary> 129 /// <param name="sender"></param> 130 /// <param name="e"></param> 131 private void btn_Send_Click(object sender, EventArgs e) 132 { 133 try 134 { 135 string strMsg = this.txt_Msg.Text.Trim(); 136 byte[] buffer = new byte[2048]; 137 buffer = Encoding.Default.GetBytes(strMsg); 138 int receive = socketSend.Send(buffer); 139 } 140 catch (Exception ex) 141 { 142 MessageBox.Show("发送消息出错:" + ex.Message); 143 } 144 } 145 146 private void FrmClient_Load(object sender, EventArgs e) 147 { 148 Control.CheckForIllegalCrossThreadCalls = false; 149 } 150 151 /// <summary> 152 /// 断开连接 153 /// </summary> 154 /// <param name="sender"></param> 155 /// <param name="e"></param> 156 private void btn_CloseConnect_Click(object sender, EventArgs e) 157 { 158 //关闭socket 159 socketSend.Close(); 160 //终止线程 161 threadReceive.Abort(); 162 } 163 } 164 }

代码测试:

Socket编程概念

using System; using System.Collections.Generic; using System.Linq; using System.Net; using System.Net.Sockets; using System.Text; using System.Threading; using System.Threading.Tasks; namespace Socket { class ClientSocket { public class SocketClientManager { public delegate int ConnectStateEventHandler(int a, int b); public event ConnectStateEventHandler ConnectedEvent;//连接成功 public event ConnectStateEventHandler DisConnectedEvent;//连接失败 public delegate void ReceiveMsgEventHandler(byte[] order); public event ReceiveMsgEventHandler ReceiveMsgEvent; static System.Net.Sockets.Socket _socket = null; static IPEndPoint iep = null; static bool isConnecting = false; static bool isConnected = false; public SocketClientManager(string strIP, int port) { _socket = new System.Net.Sockets.Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); IPAddress IP = IPAddress.Parse(strIP.Trim()); iep = new IPEndPoint(IP, port); } public void Start() { isConnecting = true; ConnectedEvent += send1; DisConnectedEvent += send1; int aa = DisConnectedEvent(1, 2); Thread t = new Thread(Connect); t.IsBackground = true; t.Start(); } private void Connect() { while (isConnecting) { try { if (!IsSocketConnected(_socket) && DisConnectedEvent != null) { DisConnectedEvent(); isConnected = false; socketClient = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); socketClient.Connect(ipMarking); ConnectedEvent(); isConnected = true; Thread t = new Thread(RecMsg); t.IsBackground = true; t.Start(socketClient); } } catch { } finally { Thread.Sleep(500); } } } private void ReceiveMsg() { try { while (isConnected) { byte[] buffer = new byte[32]; int count = _socket.Receive(buffer); if (count > 0 && ReceiveMsgEvent != null) { ReceiveMsgEvent(buffer); } } } catch { } } public void SendMsg(byte[] order) { _socket.Send(order, order.Length, SocketFlags.None); } private bool IsSocketConnected(System.Net.Sockets.Socket s) { } private static int send1(int a, int b) { int aa = 0; return aa; } } } } 

Socket编程概念

 Socket编程概念

示例程序2:

Socket编程概念

using System; using System.Collections.Generic; using System.ComponentModel; using System.Data; using System.Drawing; using System.Text; using System.Windows.Forms; using System.Net.Sockets; using System.Net; using System.Threading; using static System.Windows.Forms.VisualStyles.VisualStyleElement; namespace Socket网络编程 { public partial class SocketForm : Form { //将远程连接的客户端的IP地址和Socket存入集合中 Dictionary<string, Socket> dicSocketServer = new Dictionary<string, Socket>(); //将远程连接的客户端的IP地址和Socket存入集合中 Dictionary<string, Socket> dicSocketClient = new Dictionary<string, Socket>(); public SocketForm() { InitializeComponent(); } private void Form1_Load(object sender, EventArgs e) { CheckForIllegalCrossThreadCalls = false;//可以跨线程操作 } private void creatM_Click(object sender, EventArgs e) { Socket socketWatch = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);///创建IP地址家族 流类型 TPC协议监听对象 IPAddress IP = IPAddress.Parse(sIP.Text);///获取文本框的IP地址给Socket的IP IPEndPoint Pt = new IPEndPoint(IP, Convert.ToInt32(sPort.Text)); ///创建以设置的IP地址的端口号 sReceiveTxt.Text = "正在监听....\r\n";//显示监听状态 socketWatch.Bind(Pt);///监听此端口号 socketWatch.Listen(10);//设置最多连接个数 creatM.Enabled = false;//不能再按按钮监听了否则出错 Thread th = new Thread(socketWait);///创建新线程来等待连接的客户端 th.IsBackground = true;// 设置为后台线程 程序关闭就自动关闭 th.Start(socketWatch);///用新线程调用等待连接 } Socket ServerSR;//定义在外面 /// <summary> /// 等待客户端的连接 /// </summary> /// <param name="o"></param> private void socketWait(object o) { while (true) { Socket socketWatch = o as Socket;//将父类转换成子类的Socket ServerSR = socketWatch.Accept();///等待客户端连接 一旦有连接就把该连接值返回给ServerSR sReceiveTxt.AppendText(ServerSR.RemoteEndPoint.ToString() + "连接成功!");//显示连接状态 comboBox1.Items.Add(ServerSR.RemoteEndPoint.ToString()); dicSocketServer.Add(ServerSR.RemoteEndPoint.ToString(), ServerSR); comboBox1.SelectedIndex = 0; Thread th = new Thread(serverR);///创建新线程来接收客户端发来的消息 th.IsBackground = true;//设置为后台线程 th.Start(ServerSR);//开始接收 } } /// <summary> /// 服务器发送给客户端内容 /// </summary> /// <param name="sender"></param> /// <param name="e"></param> private void sSend_Click(object sender, EventArgs e) { string str = sSendTxt.Text.Trim() + "\r\n";///服务器发送内容 byte[] buffer = System.Text.Encoding.Default.GetBytes(str);//用字符串转换成字节数组再发送 // ServerSR.Send(buffer);///发送 string s = comboBox1.SelectedItem.ToString(); dicSocketServer[s].Send(buffer); } /// <summary> /// 清空服务器接收区 /// </summary> /// <param name="sender"></param> /// <param name="e"></param> private void emptysReceive_Click(object sender, EventArgs e) { sReceiveTxt.Text = ""; } /// <summary> /// 服务器接收客户端发来的内容 /// </summary> private void serverR(object obj) { Socket socket = obj as Socket; while (true) { byte[] buffer = new byte[1024 * 1024 * 5]; //int len = ServerSR.Receive(buffer); int len = socket.Receive(buffer); if (len == 0) { //break; } sReceiveTxt.AppendText($"{socket.RemoteEndPoint}:" + System.Text.Encoding.Default.GetString(buffer) + "\r\n"); ///客户端接收区追加数据 } } Socket ClientSR;//定义在外面 /// <summary> /// 客户端连接服务器 /// </summary> /// <param name="sender"></param> /// <param name="e"></param> private void connect_Click(object sender, EventArgs e) { ClientSR = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);///创建IP地址家族 流类型 TPC协议监听对象 IPAddress IP = IPAddress.Parse(cIP.Text);///获取文本框的IP地址给Socket的IP IPEndPoint Pt = new IPEndPoint(IP, Convert.ToInt32(cPort.Text)); ///创建以设置的IP地址的端口号 ClientSR.Connect(Pt);///连接服务器 comboBox2.Items.Add(ClientSR.LocalEndPoint.ToString()); dicSocketClient.Add(ClientSR.LocalEndPoint.ToString(), ClientSR); comboBox2.SelectedIndex = 0; Thread th = new Thread(clientR);///创建新线程来循环接收数据 th.IsBackground = true;//设置为后台线程 th.Start(ClientSR);//使用新线程接收数据 } /// <summary> /// 清空客户端接收区 /// </summary> /// <param name="sender"></param> /// <param name="e"></param> private void emptycReceive_Click(object sender, EventArgs e) { cReceiveTxt.Text = ""; } /// <summary> /// 客户端接收服务器发来的内容 /// </summary> private void clientR(object obj) { Socket socket = obj as Socket; while (true) { byte[] buffer = new byte[1024 * 1024 * 5]; int len = ClientSR.Receive(buffer); if (len == 0) { // break; } cReceiveTxt.AppendText($"{socket.LocalEndPoint}:" + System.Text.Encoding.Default.GetString(buffer) + "\r\n"); ///客户端接收区追加数据 } } /// <summary> /// 客户端发送给服务器的内容 /// </summary> /// <param name="sender"></param> /// <param name="e"></param> private void cSend_Click(object sender, EventArgs e) { string str = cSendTxt.Text.Trim() + "\r\n";///客户端发送内容 byte[] buffer = System.Text.Encoding.Default.GetBytes(str);//用字符串转换成字节数组再发送 // ClientSR.Send(buffer);///发送 string s = comboBox2.SelectedItem.ToString(); dicSocketClient[s].Send(buffer); } } } 
namespace Socket网络编程 { partial class SocketForm { /// <summary> /// 必需的设计器变量。 /// </summary> private System.ComponentModel.IContainer components = null; /// <summary> /// 清理所有正在使用的资源。 /// </summary> /// <param name="disposing">如果应释放托管资源,为 true;否则为 false。</param> protected override void Dispose(bool disposing) { if (disposing && (components != null)) { components.Dispose(); } base.Dispose(disposing); } #region Windows 窗体设计器生成的代码 /// <summary> /// 设计器支持所需的方法 - 不要 /// 使用代码编辑器修改此方法的内容。 /// </summary> private void InitializeComponent() { this.sIP = new System.Windows.Forms.TextBox(); this.groupBox1 = new System.Windows.Forms.GroupBox(); this.label4 = new System.Windows.Forms.Label(); this.label3 = new System.Windows.Forms.Label(); this.emptysReceive = new System.Windows.Forms.Button(); this.sSend = new System.Windows.Forms.Button(); this.sSendTxt = new System.Windows.Forms.TextBox(); this.sReceiveTxt = new System.Windows.Forms.TextBox(); this.creatM = new System.Windows.Forms.Button(); this.label2 = new System.Windows.Forms.Label(); this.label1 = new System.Windows.Forms.Label(); this.sPort = new System.Windows.Forms.TextBox(); this.groupBox2 = new System.Windows.Forms.GroupBox(); this.label5 = new System.Windows.Forms.Label(); this.label6 = new System.Windows.Forms.Label(); this.emptycReceive = new System.Windows.Forms.Button(); this.cSend = new System.Windows.Forms.Button(); this.cSendTxt = new System.Windows.Forms.TextBox(); this.cReceiveTxt = new System.Windows.Forms.TextBox(); this.connect = new System.Windows.Forms.Button(); this.label7 = new System.Windows.Forms.Label(); this.label8 = new System.Windows.Forms.Label(); this.cPort = new System.Windows.Forms.TextBox(); this.cIP = new System.Windows.Forms.TextBox(); this.comboBox1 = new System.Windows.Forms.ComboBox(); this.comboBox2 = new System.Windows.Forms.ComboBox(); this.groupBox1.SuspendLayout(); this.groupBox2.SuspendLayout(); this.SuspendLayout(); // // sIP // this.sIP.Location = new System.Drawing.Point(69, 18); this.sIP.Margin = new System.Windows.Forms.Padding(4, 4, 4, 4); this.sIP.Name = "sIP"; this.sIP.Size = new System.Drawing.Size(169, 25); this.sIP.TabIndex = 0; this.sIP.Text = "127.0.0.1"; // // groupBox1 // this.groupBox1.Controls.Add(this.comboBox1); this.groupBox1.Controls.Add(this.label4); this.groupBox1.Controls.Add(this.label3); this.groupBox1.Controls.Add(this.emptysReceive); this.groupBox1.Controls.Add(this.sSend); this.groupBox1.Controls.Add(this.sSendTxt); this.groupBox1.Controls.Add(this.sReceiveTxt); this.groupBox1.Controls.Add(this.creatM); this.groupBox1.Controls.Add(this.label2); this.groupBox1.Controls.Add(this.label1); this.groupBox1.Controls.Add(this.sPort); this.groupBox1.Controls.Add(this.sIP); this.groupBox1.Location = new System.Drawing.Point(16, 15); this.groupBox1.Margin = new System.Windows.Forms.Padding(4, 4, 4, 4); this.groupBox1.Name = "groupBox1"; this.groupBox1.Padding = new System.Windows.Forms.Padding(4, 4, 4, 4); this.groupBox1.Size = new System.Drawing.Size(377, 464); this.groupBox1.TabIndex = 1; this.groupBox1.TabStop = false; this.groupBox1.Text = "服务器"; // // label4 // this.label4.AutoSize = true; this.label4.Location = new System.Drawing.Point(8, 288); this.label4.Margin = new System.Windows.Forms.Padding(4, 0, 4, 0); this.label4.Name = "label4"; this.label4.Size = new System.Drawing.Size(52, 15); this.label4.TabIndex = 9; this.label4.Text = "接收区"; // // label3 // this.label3.AutoSize = true; this.label3.Location = new System.Drawing.Point(8, 96); this.label3.Margin = new System.Windows.Forms.Padding(4, 0, 4, 0); this.label3.Name = "label3"; this.label3.Size = new System.Drawing.Size(52, 15); this.label3.TabIndex = 8; this.label3.Text = "发送区"; // // emptysReceive // this.emptysReceive.Location = new System.Drawing.Point(268, 274); this.emptysReceive.Margin = new System.Windows.Forms.Padding(4, 4, 4, 4); this.emptysReceive.Name = "emptysReceive"; this.emptysReceive.Size = new System.Drawing.Size(100, 29); this.emptysReceive.TabIndex = 7; this.emptysReceive.Text = "清空接收"; this.emptysReceive.UseVisualStyleBackColor = true; this.emptysReceive.Click += new System.EventHandler(this.emptysReceive_Click); // // sSend // this.sSend.Location = new System.Drawing.Point(268, 82); this.sSend.Margin = new System.Windows.Forms.Padding(4, 4, 4, 4); this.sSend.Name = "sSend"; this.sSend.Size = new System.Drawing.Size(100, 29); this.sSend.TabIndex = 6; this.sSend.Text = "发送"; this.sSend.UseVisualStyleBackColor = true; this.sSend.Click += new System.EventHandler(this.sSend_Click); // // sSendTxt // this.sSendTxt.Location = new System.Drawing.Point(8, 116); this.sSendTxt.Margin = new System.Windows.Forms.Padding(4, 4, 4, 4); this.sSendTxt.Multiline = true; this.sSendTxt.Name = "sSendTxt"; this.sSendTxt.ScrollBars = System.Windows.Forms.ScrollBars.Vertical; this.sSendTxt.Size = new System.Drawing.Size(359, 145); this.sSendTxt.TabIndex = 5; // // sReceiveTxt // this.sReceiveTxt.Location = new System.Drawing.Point(8, 318); this.sReceiveTxt.Margin = new System.Windows.Forms.Padding(4, 4, 4, 4); this.sReceiveTxt.Multiline = true; this.sReceiveTxt.Name = "sReceiveTxt"; this.sReceiveTxt.ScrollBars = System.Windows.Forms.ScrollBars.Vertical; this.sReceiveTxt.Size = new System.Drawing.Size(359, 145); this.sReceiveTxt.TabIndex = 4; // // creatM // this.creatM.Location = new System.Drawing.Point(268, 51); this.creatM.Margin = new System.Windows.Forms.Padding(4, 4, 4, 4); this.creatM.Name = "creatM"; this.creatM.Size = new System.Drawing.Size(100, 29); this.creatM.TabIndex = 2; this.creatM.Text = "创建监听"; this.creatM.UseVisualStyleBackColor = true; this.creatM.Click += new System.EventHandler(this.creatM_Click); // // label2 // this.label2.AutoSize = true; this.label2.Location = new System.Drawing.Point(244, 22); this.label2.Margin = new System.Windows.Forms.Padding(4, 0, 4, 0); this.label2.Name = "label2"; this.label2.Size = new System.Drawing.Size(52, 15); this.label2.TabIndex = 3; this.label2.Text = "端口号"; // // label1 // this.label1.AutoSize = true; this.label1.Location = new System.Drawing.Point(7, 21); this.label1.Margin = new System.Windows.Forms.Padding(4, 0, 4, 0); this.label1.Name = "label1"; this.label1.Size = new System.Drawing.Size(53, 15); this.label1.TabIndex = 2; this.label1.Text = "IP地址"; // // sPort // this.sPort.Location = new System.Drawing.Point(300, 18); this.sPort.Margin = new System.Windows.Forms.Padding(4, 4, 4, 4); this.sPort.Name = "sPort"; this.sPort.Size = new System.Drawing.Size(68, 25); this.sPort.TabIndex = 1; this.sPort.Text = "12000"; // // groupBox2 // this.groupBox2.Controls.Add(this.comboBox2); this.groupBox2.Controls.Add(this.label5); this.groupBox2.Controls.Add(this.label6); this.groupBox2.Controls.Add(this.emptycReceive); this.groupBox2.Controls.Add(this.cSend); this.groupBox2.Controls.Add(this.cSendTxt); this.groupBox2.Controls.Add(this.cReceiveTxt); this.groupBox2.Controls.Add(this.connect); this.groupBox2.Controls.Add(this.label7); this.groupBox2.Controls.Add(this.label8); this.groupBox2.Controls.Add(this.cPort); this.groupBox2.Controls.Add(this.cIP); this.groupBox2.Location = new System.Drawing.Point(411, 15); this.groupBox2.Margin = new System.Windows.Forms.Padding(4, 4, 4, 4); this.groupBox2.Name = "groupBox2"; this.groupBox2.Padding = new System.Windows.Forms.Padding(4, 4, 4, 4); this.groupBox2.Size = new System.Drawing.Size(377, 464); this.groupBox2.TabIndex = 10; this.groupBox2.TabStop = false; this.groupBox2.Text = "客户端"; // // label5 // this.label5.AutoSize = true; this.label5.Location = new System.Drawing.Point(8, 288); this.label5.Margin = new System.Windows.Forms.Padding(4, 0, 4, 0); this.label5.Name = "label5"; this.label5.Size = new System.Drawing.Size(52, 15); this.label5.TabIndex = 9; this.label5.Text = "接收区"; // // label6 // this.label6.AutoSize = true; this.label6.Location = new System.Drawing.Point(8, 96); this.label6.Margin = new System.Windows.Forms.Padding(4, 0, 4, 0); this.label6.Name = "label6"; this.label6.Size = new System.Drawing.Size(52, 15); this.label6.TabIndex = 8; this.label6.Text = "发送区"; // // emptycReceive // this.emptycReceive.Location = new System.Drawing.Point(268, 274); this.emptycReceive.Margin = new System.Windows.Forms.Padding(4, 4, 4, 4); this.emptycReceive.Name = "emptycReceive"; this.emptycReceive.Size = new System.Drawing.Size(100, 29); this.emptycReceive.TabIndex = 7; this.emptycReceive.Text = "清空接收"; this.emptycReceive.UseVisualStyleBackColor = true; this.emptycReceive.Click += new System.EventHandler(this.emptycReceive_Click); // // cSend // this.cSend.Location = new System.Drawing.Point(268, 82); this.cSend.Margin = new System.Windows.Forms.Padding(4, 4, 4, 4); this.cSend.Name = "cSend"; this.cSend.Size = new System.Drawing.Size(100, 29); this.cSend.TabIndex = 6; this.cSend.Text = "发送"; this.cSend.UseVisualStyleBackColor = true; this.cSend.Click += new System.EventHandler(this.cSend_Click); // // cSendTxt // this.cSendTxt.Location = new System.Drawing.Point(8, 115); this.cSendTxt.Margin = new System.Windows.Forms.Padding(4, 4, 4, 4); this.cSendTxt.Multiline = true; this.cSendTxt.Name = "cSendTxt"; this.cSendTxt.ScrollBars = System.Windows.Forms.ScrollBars.Vertical; this.cSendTxt.Size = new System.Drawing.Size(359, 145); this.cSendTxt.TabIndex = 5; // // cReceiveTxt // this.cReceiveTxt.Location = new System.Drawing.Point(9, 310); this.cReceiveTxt.Margin = new System.Windows.Forms.Padding(4, 4, 4, 4); this.cReceiveTxt.Multiline = true; this.cReceiveTxt.Name = "cReceiveTxt"; this.cReceiveTxt.ScrollBars = System.Windows.Forms.ScrollBars.Vertical; this.cReceiveTxt.Size = new System.Drawing.Size(359, 145); this.cReceiveTxt.TabIndex = 4; // // connect // this.connect.Location = new System.Drawing.Point(268, 51); this.connect.Margin = new System.Windows.Forms.Padding(4, 4, 4, 4); this.connect.Name = "connect"; this.connect.Size = new System.Drawing.Size(100, 29); this.connect.TabIndex = 2; this.connect.Text = "连接"; this.connect.UseVisualStyleBackColor = true; this.connect.Click += new System.EventHandler(this.connect_Click); // // label7 // this.label7.AutoSize = true; this.label7.Location = new System.Drawing.Point(244, 22); this.label7.Margin = new System.Windows.Forms.Padding(4, 0, 4, 0); this.label7.Name = "label7"; this.label7.Size = new System.Drawing.Size(52, 15); this.label7.TabIndex = 3; this.label7.Text = "端口号"; // // label8 // this.label8.AutoSize = true; this.label8.Location = new System.Drawing.Point(7, 21); this.label8.Margin = new System.Windows.Forms.Padding(4, 0, 4, 0); this.label8.Name = "label8"; this.label8.Size = new System.Drawing.Size(53, 15); this.label8.TabIndex = 2; this.label8.Text = "IP地址"; // // cPort // this.cPort.Location = new System.Drawing.Point(300, 18); this.cPort.Margin = new System.Windows.Forms.Padding(4, 4, 4, 4); this.cPort.Name = "cPort"; this.cPort.Size = new System.Drawing.Size(68, 25); this.cPort.TabIndex = 1; this.cPort.Text = "12000"; // // cIP // this.cIP.Location = new System.Drawing.Point(69, 18); this.cIP.Margin = new System.Windows.Forms.Padding(4, 4, 4, 4); this.cIP.Name = "cIP"; this.cIP.Size = new System.Drawing.Size(169, 25); this.cIP.TabIndex = 0; this.cIP.Text = "127.0.0.1"; // // comboBox1 // this.comboBox1.FormattingEnabled = true; this.comboBox1.Location = new System.Drawing.Point(69, 56); this.comboBox1.Name = "comboBox1"; this.comboBox1.Size = new System.Drawing.Size(169, 23); this.comboBox1.TabIndex = 10; // // comboBox2 // this.comboBox2.FormattingEnabled = true; this.comboBox2.Location = new System.Drawing.Point(69, 55); this.comboBox2.Name = "comboBox2"; this.comboBox2.Size = new System.Drawing.Size(169, 23); this.comboBox2.TabIndex = 11; // // SocketForm // this.AutoScaleDimensions = new System.Drawing.SizeF(8F, 15F); this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; this.ClientSize = new System.Drawing.Size(811, 494); this.Controls.Add(this.groupBox2); this.Controls.Add(this.groupBox1); this.Margin = new System.Windows.Forms.Padding(4, 4, 4, 4); this.Name = "SocketForm"; this.Text = "Socket"; this.Load += new System.EventHandler(this.Form1_Load); this.groupBox1.ResumeLayout(false); this.groupBox1.PerformLayout(); this.groupBox2.ResumeLayout(false); this.groupBox2.PerformLayout(); this.ResumeLayout(false); } #endregion private System.Windows.Forms.TextBox sIP; private System.Windows.Forms.GroupBox groupBox1; private System.Windows.Forms.TextBox sReceiveTxt; private System.Windows.Forms.Button creatM; private System.Windows.Forms.Label label2; private System.Windows.Forms.Label label1; private System.Windows.Forms.TextBox sPort; private System.Windows.Forms.Button emptysReceive; private System.Windows.Forms.Button sSend; private System.Windows.Forms.TextBox sSendTxt; private System.Windows.Forms.Label label4; private System.Windows.Forms.Label label3; private System.Windows.Forms.GroupBox groupBox2; private System.Windows.Forms.Label label5; private System.Windows.Forms.Label label6; private System.Windows.Forms.Button emptycReceive; private System.Windows.Forms.Button cSend; private System.Windows.Forms.TextBox cSendTxt; private System.Windows.Forms.TextBox cReceiveTxt; private System.Windows.Forms.Button connect; private System.Windows.Forms.Label label7; private System.Windows.Forms.Label label8; private System.Windows.Forms.TextBox cPort; private System.Windows.Forms.TextBox cIP; private System.Windows.Forms.ComboBox comboBox1; private System.Windows.Forms.ComboBox comboBox2; } } 

 C#中Socket连接请求的超时设置

private readonly ManualResetEvent TimeoutObject = new ManualResetEvent(false); /// <summary> /// Socket连接请求 /// </summary> ///<param name="remoteEndPoint">网络端点</param> ///<param name="timeoutMSec">超时时间</param> public void Connect(IPEndPoint remoteEndPoint, int timeoutMSec) { TimeoutObject.Reset(); var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); socket.BeginConnect(remoteEndPoint, CallBackMethod, socket); //阻塞当前线程 if (TimeoutObject.WaitOne(timeoutMSec, false)) { //MessageBox.Show("网络正常"); } else { //MessageBox.Show("连接超时"); } } //--异步回调方法 private void CallBackMethod(IAsyncResult asyncresult) { //使阻塞的线程继续 TimeoutObject.Set(); }

心跳包机制

心跳机制是定时发送一个自定义的结构体(心跳包),让对方知道自己还活着,以确保连接的有效性的机制。(看下图)

img_253e0469b97a9368cd3f4da448a82642.jpe

img_5077985d1db84eebf49394a8a409b7e9.jpe

网络中的接收和发送数据都是使用操作系统中的SOCKET进行实现。但是如果此套接字已经断开,那发送数据和接收数据的时候就一定会有问题。可是如何判断这个套接字是否还可以使用呢?这个就需要在系统中创建心跳机制。其实TCP中已经为我们实现了一个叫做心跳的机制。如果你设置了心跳,那TCP就会在一定的时间(比如你设置的是3秒钟)内发送你设置的次数的心跳(比如说2次),并且此信息不会影响你自己定义的协议。所谓“心跳”就是定时发送一个自定义的结构体(心跳包或心跳帧),让对方知道自己“在线”。 以确保链接的有效性。
所谓的心跳包就是客户端定时发送简单的信息给服务器端告诉它我还在而已。代码就是每隔几分钟发送一个固定信息给服务端,服务端收到后回复一个固定信息如果服务端几分钟内没有收到客户端信息则视客户端断开。比如有些通信软件长时间不使用,要想知道它的状态是在线还是离线就需要心跳包,定时发包收包。发包方:可以是客户也可以是服务端,看哪边实现方便合理。一般是客户端。服务器也可以定时轮询发心跳下去。心跳包之所以叫心跳包是因为:它像心跳一样每隔固定时间发一次,以此来告诉服务器,这个客户端还活着。事实上这是为了保持长连接,至于这个包的内容,是没有什么特别规定的,不过一般都是很小的包,或者只包含包头的一个空包。
在TCP的机制里面,本身是存在有心跳包的机制的,也就是TCP的选项。系统默认是设置的是2小时的心跳频率。但是它检查不到机器断电、网线拔出、防火墙这些断线。而且逻辑层处理断线可能也不是那么好处理。一般,如果只是用于保活还是可以的。心跳包一般来说都是在逻辑层发送空的包来实现的。下一个定时器,在一定时间间隔下发送一个空包给客户端,然后客户端反馈一个同样的空包回来,服务器如果在一定时间内收不到客户端发送过来的反馈包,那就只有认定说掉线了。只需要send或者recv一下,如果结果为零,则为掉线。
但是,在长连接下,有可能很长一段时间都没有数据往来。理论上说,这个连接是一直保持连接的,但是实际情况中,如果中间节点出现什么故障是难以知道的。更要命的是,有的节点(防火墙)会自动把一定时间之内没有数据交互的连接给断掉。在这个时候,就需要我们的心跳包了,用于维持长连接,保活。在获知了断线之后,服务器逻辑可能需要做一些事情,比如断线后的数据清理呀,重新连接呀当然,这个自然是要由逻辑层根据需求去做了。总的来说,心跳包主要也就是用于长连接的保活和断线处理。一般的应用下,判定时间在30-40秒比较不错。如果实在要求高,那就在6-9秒。


心跳检测步骤:
1.客户端每隔一个时间间隔发生一个探测包给服务器
2.客户端发包时启动一个超时定时器
3.服务器端接收到检测包,应该回应一个包
4.如果客户机收到服务器的应答包,则说明服务器正常,删除超时定时器
5.如果客户端的超时定时器超时,依然没有收到应答包,则说明服务器挂了




心跳包的发送,通常有两种技术
  • 方法1:应用层自己实现的心跳包
    由应用程序自己发送心跳包来检测连接是否正常,大致的方法是:服务器在一个 Timer事件中定时 向客户端发送一个短小精悍的数据包,然后启动一个低级别的线程,在该线程中不断检测客户端的回应, 如果在一定时间内没有收到客户端的回应,即认为客户端已经掉线;同样,如果客户端在一定时间内没 有收到服务器的心跳包,则认为连接不可用。
  • 方法2:TCP的KeepAlive保活机制
    因为要考虑到一个服务器通常会连接多个客户端,因此由用户在应用层自己实现心跳包,代码较多 且稍显复杂,而利用TCP/IP协议层为内置的KeepAlive功能来实现心跳功能则简单得多。 不论是服务端还是客户端,一方开启KeepAlive功能后,就会自动在规定时间内向对方发送心跳包, 而另一方在收到心跳包后就会自动回复,以告诉对方我仍然在线。 因为开启KeepAlive功能需要消耗额外的宽带和流量,所以TCP协议层默认并不开启KeepAlive功能,尽管这微不足道,但在按流量计费的环境下增加了费用,另一方面,KeepAlive设置不合理时可能会 因为短暂的网络波动而断开健康的TCP连接。并且,默认的KeepAlive超时需要7,200,000 MilliSeconds, 即2小时,探测次数为5次。对于很多服务端应用程序来说,2小时的空闲时间太长。因此,我们需要手工开启KeepAlive功能并设置合理的KeepAlive参数。

Linux 进程、线程、文件描述符的底层原理

说到进程,恐怕面试中最常见的问题就是线程和进程的关系了,那么先说一下答案:在 Linux 系统中,进程和线程几乎没有区别。Linux 中的进程就是一个数据结构,看明白就可以理解文件描述符、重定向、管道命令的底层工作原理,最后我们从操作系统的角度看看为什么说线程和进程基本没有区别。

一、进程是什么

首先,抽象地来说,我们的计算机就是这个东西:Socket编程概念

这个大的矩形表示计算机的内存空间,其中的小矩形代表进程,左下角的圆形表示磁盘,右下角的图形表示一些输入输出设备,比如鼠标键盘显示器等等。另外,注意到内存空间被划分为了两块,上半部分表示用户空间,下半部分表示内核空间
用户空间装着用户进程需要使用的资源,比如你在程序代码里开一个数组,这个数组肯定存在用户空间;内核空间存放内核进程需要加载的系统资源,这一些资源一般是不允许用户访问的。但是注意有的用户进程会共享一些内核空间的资源,比如一些动态链接库等等。
我们用 C 语言写一个 hello 程序,编译后得到一个可执行文件,在命令行运行就可以打印出一句 hello world,然后程序退出。在操作系统层面,就是新建了一个进程,这个进程将我们编译出来的可执行文件读入内存空间,然后执行,最后退出。

你编译好的那个可执行程序只是一个文件,不是进程,可执行文件必须要载入内存,包装成一个进程才能真正跑起来。进程是要依靠操作系统创建的,每个进程都有它的固有属性,比如进程号(PID)、进程状态、打开的文件等等,进程创建好之后,读入你的程序,你的程序才被系统执行。
那么,操作系统是如何创建进程的呢?对于操作系统,进程就是一个数据结构,我们直接来看 Linux 的源码:

struct task_struct { // 进程状态 long state; // 虚拟内存结构体 struct mm_struct *mm; // 进程号 pid_t pid; // 指向父进程的指针 struct task_struct __rcu *parent; // 子进程列表 struct list_head children; // 存放文件系统信息的指针 struct fs_struct *fs; // 一个数组,包含该进程打开的文件指针 struct files_struct *files; };

task_struct就是 Linux 内核对于一个进程的描述,也可以称为「进程描述符」。源码比较复杂,我这里就截取了一小部分比较常见的。其中比较有意思的是mm指针和files指针。mm指向的是进程的虚拟内存,也就是载入资源和可执行文件的地方;files指针指向一个数组,这个数组里装着所有该进程打开的文件的指针

二、文件描述符是什么

先说files,它是一个文件指针数组。一般来说,一个进程会从files[0]读取输入,将输出写入files[1],将错误信息写入files[2]
有个小疑问 files指针不是指向一个结构体吗?
为啥答主说“files指针指向一个数组,这个数组里装着所有该进程打开的文件的指针。”
我理解:应该说文件(file)指针指向一个结构体,这个结构体的指针放在files数组中


举个例子,以我们的角度 C 语言的printf函数是向命令行打印字符,但是从进程的角度来看,就是向files[1]写入数据;同理,scanf函数就是进程试图从files[0]这个文件中读取数据。
每个进程被创建时,files的前三位被填入默认值,分别指向标准输入流、标准输出流、标准错误流。我们常说的「文件描述符」就是指这个文件指针数组的索引,所以程序的文件描述符默认情况下 0 是输入,1 是输出,2 是错误。
我们可以重新画一幅图:



Socket编程概念

 明白了这个原理,输入重定向就很好理解了,程序想读取数据的时候就会去files[0]读取,所以我们只要把files[0]指向一个文件,那么程序就会从这个文件中读取数据,而不是从键盘:

$ command < file.txt

Socket编程概念

$ cmd1 | cmd2 | cmd3

Socket编程概念

 到这里,你可能也看出「Linux 中一切皆文件」设计思路的高明了,不管是设备、另一个进程、socket 套接字还是真正的文件,全部都可以读写,统一装进一个简单的files数组,进程通过简单的文件描述符访问相应资源,具体细节交于操作系统,有效解耦,优美高效。

三、线程是什么

Socket编程概念

文件描述符

这里涉及到两个文件描述符:

  • listen_fd
  • correspond_fd
  • 发送数据时会调用send/write,这是把数据写入到内核的写缓冲区,若内核检测到该区有数据,则准备发送;
  • 接受数据时会调用recv/read,这是把数据从内核的读缓冲区读出来,若该区没有数据,则会阻塞。

Linux文件描述符到底是什么?

Linux 中一切都可以看作文件,包括普通文件、链接文件、Socket 以及设备驱动等,对其进行相关操作时,都可能会创建对应的文件描述符。文件描述符(file descriptor)是内核为了高效管理已被打开的文件所创建的索引,用于指代被打开的文件,对文件所有 I/O 操作相关的系统调用都需要通过文件描述符。Linux 中一切皆文件,比如 C++ 源文件、视频文件、Shell脚本、可执行文件等,就连键盘、显示器、鼠标等硬件设备也都是文件。
一个 Linux 进程可以打开成百上千个文件,为了表示和区分已经打开的文件,Linux 会给每个文件分配一个编号(一个 ID),这个编号就是一个整数,被称为文件描述符(File Descriptor)。
这只是一个形象的比喻,为了让读者容易理解我才这么说。如果你也仅仅理解到这个层面,那不过是浅尝辄止而已,并没有看到文件描述符的本质。
本篇文章的目的就是拨云见雾,从底层实现的角度来给大家剖析一下文件描述符,看看文件描述如到底是如何表示一个文件的。
不过,阅读本篇文章需要你有C语言编程基础,至少要理解数组、指针和结构体;如果理解内存,那就更好了,看了这篇文章你会醍醐灌顶。

Linux 文件描述符到底是什么?一个 Linux 进程启动后,会在内核空间中创建一个 PCB 控制块,PCB 内部有一个文件描述符表(File descriptor table),记录着当前进程所有可用的文件描述符,也即当前进程所有打开的文件。





为了描述控制进程的运行,系统中存放进程的管理和控制信息的数据结构称为进程控制块(PCB Process Control Block),它是进程实体的一部分,是操作系统中最重要的记录性数据结构。它是进程管理和控制的最重要的数据结构,每一个进程均有一个PCB,在创建进程时,建立PCB,伴随进程运行的全过程,直到进程撤消而撤消。

内核空间是虚拟地址空间的一部分, 《
C语言内存精讲》, 可以这样理解:进程启动后要占用内存,其中一部分内存分配给了文件描述符表。

除了文件描述符表,系统还需要维护另外两张表:

  • 打开文件表(Open file table)
  • i-node 表(i-node table)

文件描述符表每个进程都有一个,打开文件表和 i-node 表整个系统只有一个,它们三者之间的关系如下图所示。

Socket编程概念

  • 进程级别的文件描述符表:内核为每个进程维护一个文件描述符表,该表记录了文件描述符的相关信息,包括文件描述符、指向打开文件表中记录的指针。
  • 系统级别的打开文件表:内核对所有打开文件维护的一个进程共享的打开文件描述表,表中存储了处于打开状态文件的相关信息,包括文件类型、访问权限、文件操作函数(file_operations)等。
  • 系统级别的 i-node 表:i-node 结构体记录了文件相关的信息,包括文件长度,文件所在设备,文件物理位置,创建、修改和更新时间等,”ls -i” 命令可以查看文件 i-node 节点
  • 文件偏移量,也就是文件内部指针偏移量。调用 read() 或者 write() 函数时,文件偏移量会自动更新,当然也可以使用 lseek() 直接修改。
  • 状态标志,比如只读模式、读写模式、追加模式、覆盖模式等。
  • i-node 表指针。

然而,要想真正读写文件,还得通过打开文件表的 i-node 指针进入 i-node 表,该表包含了诸如以下的信息:

  • 文件类型,例如常规文件、套接字或 FIFO。
  • 文件大小。
  • 时间戳,比如创建时间、更新时间。
  • 文件锁。

对上图的进一步说明:

  • 在进程 A 中,文件描述符 1 和 20 都指向了同一个打开文件表项,标号为 23(指向了打开文件表中下标为 23 的数组元素),这可能是通过调用 dup()、dup2()、fcntl() 或者对同一个文件多次调用了 open() 函数形成的。
  • 进程 A 的文件描述符 2 和进程 B 的文件描述符 2 都指向了同一个文件,这可能是在调用 fork() 后出现的(即进程 A、B 是父子进程关系),或者是不同的进程独自去调用 open() 函数打开了同一个文件,此时进程内部的描述符正好分配到与其他进程打开该文件的描述符一样。
  • 进程 A 的描述符 0 和进程 B 的描述符 3 分别指向不同的打开文件表项,但这些表项均指向 i-node 表的同一个条目(标号为 1976);换言之,它们指向了同一个文件。发生这种情况是因为每个进程各自对同一个文件发起了 open() 调用。同一个进程两次打开同一个文件,也会发生类似情况。 

有了以上对文件描述符的认知,我们很容易理解以下情形:

  • 同一个进程的不同文件描述符可以指向同一个文件;
  • 不同进程可以拥有相同的文件描述符;
  • 不同进程的同一个文件描述符可以指向不同的文件(一般也是这样,除了 0、1、2 这三个特殊的文件);
  • 不同进程的不同文件描述符也可以指向同一个文件。

如何服务更多的用户?C10K 问题

在改进网络 I/O 模型前,我先来提一个问题,你知道服务器单机理论最大能连接多少个客户端?

相信你知道 TCP 连接是由四元组唯一确认的,这个四元组就是:本机IP, 本机端口, 对端IP, 对端端口

服务器作为服务方,通常会在本地固定监听一个端口,等待客户端的连接。因此服务器的本地 IP 和端口是固定的,于是对于服务端 TCP 连接的四元组只有对端 IP 和端口是会变化的,所以最大 TCP 连接数 = 客户端 IP 数×客户端端口数

对于 IPv4,客户端的 IP 数最多为 2 的 32 次方,客户端的端口数最多为 2 的 16 次方,也就是服务端单机最大 TCP 连接数约为 2 的 48 次方

这个理论值相当“丰满”,但是服务器肯定承载不了那么大的连接数,主要会受两个方面的限制:

  • 文件描述符,Socket 实际上是一个文件,也就会对应一个文件描述符。在 Linux 下,单个进程打开的文件描述符数是有限制的,没有经过修改的值一般都是 1024,不过我们可以通过 ulimit 增大文件描述符的数目;
  • 系统内存,每个 TCP 连接在内核中都有对应的数据结构,意味着每个连接都是会占用一定内存的;

那如果服务器的内存只有 2 GB,网卡是千兆的,能支持并发 1 万请求吗?

并发 1 万请求,也就是经典的 C10K 问题 ,C 是 Client 单词首字母缩写,C10K 就是单机同时处理 1 万个请求的问题。

从硬件资源角度看,对于 2GB 内存千兆网卡的服务器,如果每个请求处理占用不到 200KB 的内存和 100Kbit 的网络带宽就可以满足并发 1 万个请求。

不过,要想真正实现 C10K 的服务器,要考虑的地方在于服务器的网络 I/O 模型,效率低的模型,会加重系统开销,从而会离 C10K 的目标越来越远。

多进程模型

基于最原始的阻塞网络 I/O, 如果服务器要支持多个客户端,其中比较传统的方式,就是使用多进程模型,也就是为每个客户端分配一个进程来处理请求。

服务器的主进程负责监听客户的连接,一旦与客户端连接完成,accept() 函数就会返回一个「已连接 Socket」,这时就通过 fork() 函数创建一个子进程,实际上就把父进程所有相关的东西都复制一份,包括文件描述符、内存地址空间、程序计数器、执行的代码等。
这两个进程刚复制完的时候,几乎一模一样。不过,会根据返回值来区分是父进程还是子进程,如果返回值是 0,则是子进程;如果返回值是其他的整数,就是父进程。

正因为子进程会复制父进程的文件描述符,于是就可以直接使用「已连接 Socket 」和客户端通信了,可以发现,子进程不需要关心「监听 Socket」,只需要关心「已连接 Socket」;父进程则相反,将客户服务交给子进程来处理,因此父进程不需要关心「已连接 Socket」,只需要关心「监听 Socket」。

下面这张图描述了从连接请求到连接建立,父进程创建生子进程为客户服务。

Socket编程概念

另外,当「子进程」退出时,实际上内核里还会保留该进程的一些信息,也是会占用内存的,如果不做好“回收”工作,就会变成僵尸进程,随着僵尸进程越多,会慢慢耗尽我们的系统资源。

因此,父进程要“善后”好自己的孩子,怎么善后呢?那么有两种方式可以在子进程退出后回收资源,分别是调用 wait() 和 waitpid() 函数。

这种用多个进程来应付多个客户端的方式,在应对 100 个客户端还是可行的,但是当客户端数量高达一万时,肯定扛不住的,因为每产生一个进程,必会占据一定的系统资源,而且进程间上下文切换的“包袱”是很重的,性能会大打折扣。

进程的上下文切换不仅包含了虚拟内存、栈、全局变量等用户空间的资源,还包括了内核堆栈、寄存器等内核空间的资源。

多线程模型

既然进程间上下文切换的“包袱”很重,那我们就搞个比较轻量级的模型来应对多用户的请求 —— 多线程模型

线程是运行在进程中的一个“逻辑流”,单进程中可以运行多个线程,同进程里的线程可以共享进程的部分资源,比如文件描述符列表、进程空间、代码、全局数据、堆、共享库等,这些共享些资源在上下文切换时不需要切换,而只需要切换线程的私有数据、寄存器等不共享的数据,因此同一个进程下的线程上下文切换的开销要比进程小得多。

当服务器与客户端 TCP 完成连接后,通过 pthread_create() 函数创建线程,然后将「已连接 Socket」的文件描述符传递给线程函数,接着在线程里和客户端进行通信,从而达到并发处理的目的。

如果每来一个连接就创建一个线程,线程运行完后,还得操作系统还得销毁线程,虽说线程切换的上写文开销不大,但是如果频繁创建和销毁线程,系统开销也是不小的。

那么,我们可以使用线程池的方式来避免线程的频繁创建和销毁,所谓的线程池,就是提前创建若干个线程,这样当由新连接建立时,将这个已连接的 Socket 放入到一个队列里,然后线程池里的线程负责从队列中取出「已连接 Socket 」进行处理。

Socket编程概念

需要注意的是,这个队列是全局的,每个线程都会操作,为了避免多线程竞争,线程在操作这个队列前要加锁。

上面基于进程或者线程模型的,其实还是有问题的。新到来一个 TCP 连接,就需要分配一个进程或者线程,那么如果要达到 C10K,意味着要一台机器维护 1 万个连接,相当于要维护 1 万个进程/线程,操作系统就算死扛也是扛不住的。

I/O 多路复用

既然为每个请求分配一个进程/线程的方式不合适,那有没有可能只使用一个进程来维护多个 Socket 呢?答案是有的,那就是 I/O 多路复用技术。

Socket编程概念

一个进程虽然任一时刻只能处理一个请求,但是处理每个请求的事件时,耗时控制在 1 毫秒以内,这样 1 秒内就可以处理上千个请求,把时间拉长来看,多个请求复用了一个进程,这就是多路复用,这种思想很类似一个 CPU 并发多个进程,所以也叫做时分多路复用。

我们熟悉的 select/poll/epoll 内核提供给用户态的多路复用系统调用,进程可以通过一个系统调用函数从内核中获取多个事件

select/poll/epoll 是如何获取网络事件的呢?在获取事件时,先把所有连接(文件描述符)传给内核,再由内核返回产生了事件的连接,然后在用户态中再处理这些连接对应的请求即可。

select/poll/epoll 这是三个多路复用接口,都能实现 C10K 吗?接下来,我们分别说说它们。

select/poll

select 实现多路复用的方式是,将已连接的 Socket 都放到一个文件描述符集合,然后调用 select 函数将文件描述符集合拷贝到内核里,让内核来检查是否有网络事件产生,检查的方式很粗暴,就是通过遍历文件描述符集合的方式,当检查到有事件产生后,将此 Socket 标记为可读或可写, 接着再把整个文件描述符集合拷贝回用户态里,然后用户态还需要再通过遍历的方法找到可读或可写的 Socket,然后再对其处理。

所以,对于 select 这种方式,需要进行 2 次「遍历」文件描述符集合,一次是在内核态里,一个次是在用户态里 ,而且还会发生 2 次「拷贝」文件描述符集合,先从用户空间传入内核空间,由内核修改后,再传出到用户空间中。

select 使用固定长度的 BitsMap,表示文件描述符集合,而且所支持的文件描述符的个数是有限制的,在 Linux 系统中,由内核中的 FD_SETSIZE 限制, 默认最大值为 1024,只能监听 0~1023 的文件描述符。

poll 不再用 BitsMap 来存储所关注的文件描述符,取而代之用动态数组,以链表形式来组织,突破了 select 的文件描述符个数限制,当然还会受到系统文件描述符限制。

但是 poll 和 select 并没有太大的本质区别,都是使用「线性结构」存储进程关注的 Socket 集合,因此都需要遍历文件描述符集合来找到可读或可写的 Socket,时间复杂度为 O(n),而且也需要在用户态与内核态之间拷贝文件描述符集合,这种方式随着并发数上来,性能的损耗会呈指数级增长。

epoll

先复习下 epoll 的用法。如下的代码中,先用e poll_create 创建一个 epol l对象 epfd,再通过 epoll_ctl 将需要监视的 socket 添加到epfd中,最后调用 epoll_wait 等待数据。

int s = socket(AF_INET, SOCK_STREAM, 0); bind(s, ...); listen(s, ...) int epfd = epoll_create(...); epoll_ctl(epfd, ...); //将所有需要监听的socket添加到epfd中 while(1) { int n = epoll_wait(...); for(接收到数据的socket){ //处理 } }

epoll 通过两个方面,很好解决了 select/poll 的问题。

第一点,epoll 在内核里使用红黑树来跟踪进程所有待检测的文件描述字,把需要监控的 socket 通过 epoll_ctl() 函数加入内核中的红黑树里,红黑树是个高效的数据结构,增删改一般时间复杂度是 O(logn)。而 select/poll 内核里没有类似 epoll 红黑树这种保存所有待检测的 socket 的数据结构,所以 select/poll 每次操作时都传入整个 socket 集合给内核,而 epoll 因为在内核维护了红黑树,可以保存所有待检测的 socket ,所以只需要传入一个待检测的 socket,减少了内核和用户空间大量的数据拷贝和内存分配。

第二点, epoll 使用事件驱动的机制,内核里维护了一个链表来记录就绪事件,当某个 socket 有事件发生时,通过回调函数内核会将其加入到这个就绪事件列表中,当用户调用 epoll_wait() 函数时,只会返回有事件发生的文件描述符的个数,不需要像 select/poll 那样轮询扫描整个 socket 集合,大大提高了检测的效率。

从下图你可以看到 epoll 相关的接口作用:

Socket编程概念

epoll 的方式即使监听的 Socket 数量越多的时候,效率不会大幅度降低,能够同时监听的 Socket 的数目也非常的多了,上限就为系统定义的进程打开的最大文件描述符个数。因而,epoll 被称为解决 C10K 问题的利器

插个题外话,网上文章不少说,epoll_wait 返回时,对于就绪的事件,epoll 使用的是共享内存的方式,即用户态和内核态都指向了就绪链表,所以就避免了内存拷贝消耗。

这是错的!看过 epoll 内核源码的都知道,压根就没有使用共享内存这个玩意。你可以从下面这份代码看到, epoll_wait 实现的内核代码中调用了 __put_user 函数,这个函数就是将数据从内核拷贝到用户空间。

Socket编程概念

好了,这个题外话就说到这了,我们继续!

边缘触发和水平触发

epoll 支持两种事件触发模式,分别是边缘触发(edge-triggered,ET水平触发(level-triggered,LT

这两个术语还挺抽象的,其实它们的区别还是很好理解的。

  • 使用边缘触发模式时,当被监控的 Socket 描述符上有可读事件发生时,服务器端只会从 epoll_wait 中苏醒一次,即使进程没有调用 read 函数从内核读取数据,也依然只苏醒一次,因此我们程序要保证一次性将内核缓冲区的数据读取完;
  • 使用水平触发模式时,当被监控的 Socket 上有可读事件发生时,服务器端不断地从 epoll_wait 中苏醒,直到内核缓冲区数据被 read 函数读完才结束,目的是告诉我们有数据需要读取;

举个例子,你的快递被放到了一个快递箱里,如果快递箱只会通过短信通知你一次,即使你一直没有去取,它也不会再发送第二条短信提醒你,这个方式就是边缘触发;如果快递箱发现你的快递没有被取出,它就会不停地发短信通知你,直到你取出了快递,它才消停,这个就是水平触发的方式。

这就是两者的区别,水平触发的意思是只要满足事件的条件,比如内核中有数据需要读,就一直不断地把这个事件传递给用户;而边缘触发的意思是只有第一次满足条件的时候才触发,之后就不会再传递同样的事件了。

如果使用水平触发模式,当内核通知文件描述符可读写时,接下来还可以继续去检测它的状态,看它是否依然可读或可写。所以在收到通知后,没必要一次执行尽可能多的读写操作。

如果使用边缘触发模式,I/O 事件发生时只会通知一次,而且我们不知道到底能读写多少数据,所以在收到通知后应尽可能地读写数据,以免错失读写的机会。因此,我们会循环从文件描述符读写数据,那么如果文件描述符是阻塞的,没有数据可读写时,进程会阻塞在读写函数那里,程序就没办法继续往下执行。所以,边缘触发模式一般和非阻塞 I/O 搭配使用,程序会一直执行 I/O 操作,直到系统调用(如 read 和 write)返回错误,错误类型为 EAGAIN 或 EWOULDBLOCK

一般来说,边缘触发的效率比水平触发的效率要高,因为边缘触发可以减少 epoll_wait 的系统调用次数,系统调用也是有一定的开销的的,毕竟也存在上下文的切换。

select/poll 只有水平触发模式,epoll 默认的触发模式是水平触发,但是可以根据应用场景设置为边缘触发模式。

另外,使用 I/O 多路复用时,最好搭配非阻塞 I/O 一起使用,Linux 手册关于 select 的内容中有如下说明:

Under Linux, select() may report a socket file descriptor as “ready for reading”, while nevertheless a subsequent read blocks. This could for example happen when data has arrived but upon examination has wrong checksum and is discarded. There may be other circumstances in which a file descriptor is spuriously reported as ready. Thus it may be safer to use O_NONBLOCK on sockets that should not block.

我谷歌翻译的结果:

在Linux下,select() 可能会将一个 socket 文件描述符报告为 “准备读取”,而后续的读取块却没有。例如,当数据已经到达,但经检查后发现有错误的校验和而被丢弃时,就会发生这种情况。也有可能在其他情况下,文件描述符被错误地报告为就绪。因此,在不应该阻塞的 socket 上使用 O_NONBLOCK 可能更安全。

简单点理解,就是多路复用 API 返回的事件并不一定可读写的,如果使用阻塞 I/O, 那么在调用 read/write 时则会发生程序阻塞,因此最好搭配非阻塞 I/O,以便应对极少数的特殊情况。

总结

最基础的 TCP 的 Socket 编程,它是阻塞 I/O 模型,基本上只能一对一通信,那为了服务更多的客户端,我们需要改进网络 I/O 模型。

比较传统的方式是使用多进程/线程模型,每来一个客户端连接,就分配一个进程/线程,然后后续的读写都在对应的进程/线程,这种方式处理 100 个客户端没问题,但是当客户端增大到 10000 个时,10000 个进程/线程的调度、上下文切换以及它们占用的内存,都会成为瓶颈。

为了解决上面这个问题,就出现了 I/O 的多路复用,可以只在一个进程里处理多个文件的 I/O,Linux 下有三种提供 I/O 多路复用的 API,分别是:select、poll、epoll。

select 和 poll 并没有本质区别,它们内部都是使用「线性结构」来存储进程关注的 Socket 集合。

在使用的时候,首先需要把关注的 Socket 集合通过 select/poll 系统调用从用户态拷贝到内核态,然后由内核检测事件,当有网络事件产生时,内核需要遍历进程关注 Socket 集合,找到对应的 Socket,并设置其状态为可读/可写,然后把整个 Socket 集合从内核态拷贝到用户态,用户态还要继续遍历整个 Socket 集合找到可读/可写的 Socket,然后对其处理。

很明显发现,select 和 poll 的缺陷在于,当客户端越多,也就是 Socket 集合越大,Socket 集合的遍历和拷贝会带来很大的开销,因此也很难应对 C10K。

epoll 是解决 C10K 问题的利器,通过两个方面解决了 select/poll 的问题。

  • epoll 在内核里使用「红黑树」来关注进程所有待检测的 Socket,红黑树是个高效的数据结构,增删改一般时间复杂度是 O(logn),通过对这棵黑红树的管理,不需要像 select/poll 在每次操作时都传入整个 Socket 集合,减少了内核和用户空间大量的数据拷贝和内存分配。
  • epoll 使用事件驱动的机制,内核里维护了一个「链表」来记录就绪事件,只将有事件发生的 Socket 集合传递给应用程序,不需要像 select/poll 那样轮询扫描整个集合(包含有和无事件的 Socket ),大大提高了检测的效率。

而且,epoll 支持边缘触发和水平触发的方式,而 select/poll 只支持水平触发,一般而言,边缘触发的方式会比水平触发的效率高。

socket缓冲区以及阻塞模式详解

在《socket 数据的接受和发送》一节中讲到,可以使用 write()/send() 函数发送数据,使用 read()/recv() 函数接收数据,本节就来看看数据是如何传递的。Socket编程概念

socket 缓冲区

Socket编程概念

  • I/O缓冲区在每个TCP套接字中单独存在;
  • I/O缓冲区在创建套接字时自动生成;
  • 即使关闭套接字也会继续传送输出缓冲区中遗留的数据;
  • 关闭套接字将丢失输入缓冲区中的数据。

输入输出缓冲区的默认大小一般都是 8K,可以通过 getsockopt() 函数获取:

unsigned optVal; int optLen = sizeof(int); getsockopt(servSock, SOL_SOCKET, SO_SNDBUF, (char*)&optVal, &optLen); printf("Buffer length: %d\n", optVal);
TCP套接字阻塞模式
TCP套接字默认情况下是阻塞模式,也是最常用的。当然也可以更改为非阻塞模式

socket是并发安全的??

1665647230228

也就是用户客户端直接连接游戏核心逻辑服务器,下面简称GameServer。GameServer主要负责实现各种玩法逻辑。

这当然是能跑起来,实现也很简单。但这样会有个问题,因为游戏这块蛋糕很大,所以总会遇到很多挺刑的事情。如果让用户直连GameServer,那相当于把GameServer的ip暴露给了所有人。

不赚钱还好,一旦游戏赚钱,就会遇到各种攻击。

你猜《羊了个羊》最火的时候为啥老是崩溃?

假设一个游戏服务器能承载4k玩家,一旦服务器遭受直接攻击,那4k玩家都会被影响。

1665647681905

GameServer就躲在了gateway背后,用户只能得到gateway的IP。

然后将大概每100个用户放在一个gateway里,这样如果真被攻击,就算gateway崩了,受影响的也就那100个玩家。

由于大部分游戏都使用TCP做开发,所以下面提到的连接,如果没有特别说明,那都是指TCP连接

那么问题来了。

假设有100个用户连gateway,那gateway跟GameServer之间也会是 100个连接吗?

当然不会,gateway跟GameServer之间的连接数会远小于100

因为这100个用户不会一直需要收发消息,总有空闲的时候,完全可以让多个用户复用同一条连接,将数据打包一起发送给GameServer,这样单个连接的利用率也高了,GameServer 也不再需要同时维持太多连接,可以节省了不少资源,这样就可以多服务几个大怨种金主。

我们知道,要对网络连接写数据,就要执行 send(socket_fd, data)

于是问题就来了。

已知多个用户共用同一条连接

现在多个用户要发数据,也就是多个用户线程需要写同一个socket_fd

socket是并发安全的吗?能让这多个线程同时并发写吗?

Socket编程概念

写TCP Socket是线程安全的吗?

对于TCP,我们一般使用下面的方式创建socket。

sockfd=socket(AF_INET,SOCK_STREAM, 0))

返回的sockfd是socket的句柄id,用于在整个操作系统中唯一标识你的socket是哪个,可以理解为socket的身份证id。创建socket时,操作系统内核会顺带为socket创建一个发送缓冲区和一个接收缓冲区。分别用于在发送和接收数据的时候给暂存一下数据。写socket的方式有很多,既可以是send,也可以是write。但不管哪个,最后在内核里都会走到 tcp_sendmsg() 函数下。

// net/ipv4/tcp.c int tcp_sendmsg(struct kiocb *iocb, struct sock *sk, struct msghdr *msg, size_t size) { // 加锁 lock_sock(sk); // ... 拷贝到发送缓冲区的相关操作 // 解锁 release_sock(sk); } 

 在tcp_sendmsg的目的就是将要发送的数据放入到TCP的发送缓冲区中,此时并没有所谓的发送数据出去,函数就返回了,内核后续再根据实际情况异步发送。关于这点,下面更详细的介绍。Socket编程概念Socket编程概念Socket编程概念

tcp_sendmsg的代码中可以看到,在对socket的缓冲区执行写操作的时候,linux内核已经自动帮我们加好了锁,也就是说,是线程安全的

所以可以多线程不加锁并发写入数据吗?

不能。

问题的关键在于锁的粒度
但我们知道TCP有三大特点,面向连接,可靠的,基于字节流的协议。

问题就出在这个”基于字节流“,它是个源源不断的二进制数据流,无边界。来多少就发多少,但是能发多少,得看你的发送缓冲区还剩多少空间

举个例子,假设A线程想发123数据包,B线程想发456数据包。

A和B线程同时执行send(),A先抢到锁,此时发送缓冲区就剩1个数据包的位置,那发了"1",然后发送缓冲区满了,A线程退出(非阻塞),当发送缓冲区腾出位置后,此时AB再次同时争抢,这次被B先抢到了,B发了"4"之后缓冲区又满了,不得不退出。

重复这样多次争抢之后,原本的数据内容都被打乱了,变成了。因为数据123是个整体456又是个整体,像现在这样数据被打乱的话,接收方就算收到了数据也没办法正常解析

Socket编程概念

也就是说锁的粒度其实是每次”写操作“,但每次写操作并不保证能把消息写完整

那么问题就来了,那是不是我在写整个完整消息之前加个锁,整个消息都写完之后再解锁,这样就好了?

类似下面这样。

// 伪代码 int safe_send(msg string) { target_len = length(msg) have_send_len = 0 // 加锁 lock(); // 不断循环直到发完整个完整消息 do { send_len := send(sockfd,msg) have_send_len = have_send_len + send_len } while(have_send_len < target_len) // 解锁 unlock(); } 

 这也不行,我们知道加锁这个事情是影响性能的,锁的粒度越小,性能就越好。反之性能就越差。

当我们抢到了锁,使用 send(sockfd,msg) 发送完整数据的时候,如果此时发送缓冲区正好一写就满了,那这个线程就得一直占着这个锁直到整个消息写完。其他线程都在旁边等它解锁,啥事也干不了,焦急难耐想着抢锁。
但凡某个消息体稍微大点,这样的问题就会变得更严重。整个服务的性能也会被这波神仙操作给拖垮

归根结底还是因为锁的粒度太大了

有没有更好的方式呢?

其实多个线程抢锁,最后抢到锁的线程才能进行写操作,从本质上来看,就是将所有用户发给GameServer逻辑服务器的消息给串行化了,那既然是串行化,我完全可以在在业务代码里为每个socket_fd配一个队列来做,将数据在用户态加锁后塞到这个队列里,再单独开一个线程,这个线程的工作就是发送消息给socket_fd。

于是上面的场景就变成了下面这样。

Socket编程概念

 于是在gateway层,多个用户线程同时写消息时,会去争抢某个socket_fd对应的队列,抢到锁之后就写数据到队列。而真正执行 send(sockfd,msg) 的线程其实只有一个。它会从这个队列中取数据,然后不加锁的批量发送数据到 GameServer。

由于加锁后要做的事情很简单,也就塞个队列而已,因此非常快。并且由于执行发送数据的只有单个线程,因此也不会有消息体乱序的问题。

读TCP Socket是线程安全的吗?

在前面有了写socket是线程安全的结论,我们稍微翻一下源码就能发现,读socket其实也是加锁了的,所以并发多线程读socket这件事是线程安全的

// net/ipv4/tcp.c int tcp_recvmsg(struct kiocb *iocb, struct sock *sk, struct msghdr *msg, size_t len, int nonblock, int flags, int *addr_len) { // 加锁 lock_sock(sk); // ... 将数据从接收缓冲区拷贝到用户缓冲区 // 释放锁 release_sock(sk); } 

但就算是线程安全,也不代表你可以用多个线程并发去读。

因为这个锁,只保证你在读socket 接收缓冲区时,只有一个线程在读,但并不能保证你每次的时候,都能正好读到完整消息体后才返回。

所以虽然并发读不报错,但每个线程拿到的消息肯定都不全,因为锁的粒度并不保证能读完完整消息。

TCP是基于数据流的协议,数据流会源源不断从网卡那送到接收缓冲区
如果此时接收缓冲区里有两条完整消息,比如 “我是小白“和”点赞在看走一波“。有两个线程A和B同时并发去读的话,A线程就可能读到“我是 点赞走一波“, B线程就可能读到”小白 在看“。两条消息都变得不完整了。

Socket编程概念
解决方案还是跟读的时候一样,读socket的只能有一个线程,读到了消息之后塞到加锁队列中,再将消息分开给到GameServer的多线程用户逻辑模块中去做处理。Socket编程概念

读写UDP Socket是线程安全的吗?

聊完TCP,我们很自然就能想到另外一个传输层协议UDP,那么它是线程安全的吗?

我们平时写代码的时候如果要使用udp发送消息,一般会像下面这样操作。

ssize_t sendto(int sockfd, const void *buf, size_t nbytes, int flags, const struct sockaddr *to, socklen_t addrlen);

而执行到底层,会到linux内核的udp_sendmsg函数中。

int udp_sendmsg(struct kiocb *iocb, struct sock *sk, struct msghdr *msg, size_t len) { if (用到了MSG_MORE的功能) { lock_sock(sk); // 加入到发送缓冲区中 release_sock(sk); } else { // 不加锁,直接发送消息 } }

这里我用伪代码改了下,大概的含义就是用到MSG_MORE就加锁,否则不加锁将传入的msg作为一整个数据包直接发送。

首先需要搞清楚,MSG_MORE 是啥。它可以通过上面提到的sendto函数最右边的flags字段进行设置。大概的意思是告诉内核,待会还有其他更多消息要一起发,先别着急发出去。此时内核就会把这份数据先用发送缓冲区缓存起来,待会应用层说ok了,再一起发。

但是,我们一般也用不到 MSG_MORE

所以我们直接关注另外一个分支,也就是不加锁直接发消息。

那是不是说明走了不加锁的分支时,udp发消息并不是线程安全的?

其实。还是线程安全的,不用lock_sock(sk)加锁,单纯是因为没必要

开启MSG_MORE时多个线程会同时写到同一个socket_fd对应的发送缓冲区中,然后再统一一起发送到IP层,因此需要有个锁防止出现多个线程将对方写的数据给覆盖掉的问题。而不开启MSG_MORE时,数据则会直接发送给IP层,就没有了上面的烦恼。

再看下udp的接收函数udp_recvmsg,会发现情况也类似,这里就不再赘述。

能否多线程同时并发读或写同一个UDP socket?

在TCP中,线程安全不代表你可以并发地读写同一个socket_fd,因为哪怕内核态中加了lock_sock(sk),这个锁的粒度并不覆盖整个完整消息的多次分批发送,它只保证单次发送的线程安全,所以建议只用一个线程去读写一个socket_fd。

那么问题又来了,那UDP呢?会有一样的问题吗?

我们跟TCP对比下,大家就知道了。

TCP不能用多线程同时读和同时写,是因为它是基于数据流的协议。

那UDP呢?它是基于数据报的协议。

Socket编程概念

基于数据流和基于数据报有什么区别呢?

基于数据流,意味着发给内核底层的数据就跟水进入水管一样,内核根本不知道什么时候是个头,没有明确的边界

而基于数据报,可以类比为一件件快递进入传送管道一样,内核很清楚拿到的是几件快递,快递和快递之间边界分明Socket编程概念

那从我们使用的方式来看,应用层通过TCP去发数据,TCP就先把它放到缓冲区中,然后就返回。至于什么时候发数据,发多少数据,发的数据是刚刚应用层传进去的一半还是全部都是不确定的,全看内核的心情。在接收端收的时候也一样。

但UDP就不同,UDP 对应用层交下来的报文,既不合并,也不拆分,而是保留这些报文的边界

无论应用层交给 UDP 多长的报文,UDP 都照样发送,即一次发送一个报文。至于数据包太长,需要分片,那也是IP层的事情,跟UDP没啥关系,大不了效率低一些。而接收方在接收数据报的时候,一次取一个完整的包,不存在TCP常见的半包和粘包问题。

正因为基于数据报基于字节流的差异,TCP 发送端发 10 次字节流数据,接收端可以分 100 次去取数据,每次取数据的长度可以根据处理能力作调整;但 UDP 发送端发了 10 次数据报,那接收端就要在 10 次收完,且发了多少次,就取多少次,确保每次都是一个完整的数据报。

所以从这个角度来说,UDP写数据报的行为是”原子”的,不存在发一半包或收一半包的问题,要么整个包成功,要么整个包失败。因此多个线程同时读写,也就不会有TCP的问题。

所以,可以多个线程同时读写同一个udp socket

就算可以,我依然不建议大家这么做。

为什么不建议使用多线程同时读写同一个UDP socket

udp本身是不可靠的协议,多线程高并发执行发送时,会对系统造成较大压力,这时候丢包是常见的事情。虽然这时候应用层能实现重传逻辑,但重传这件事毕竟是越少越好。因此通常还会希望能有个应用层流量控制的功能,如果是单线程读写的话,就可以在同一个地方对流量实现调控。类似的,实现其他插件功能也会更加方便,比如给某些vip等级的老板更快速的游戏体验啥的(我瞎说的)。

所以正确的做法,还是跟TCP一样,不管外面有多少个线程,还是并发加锁写到一个队列里,然后起一个单独的线程去做发送操作。

Socket编程概念

总结

  1. 多线程并发读/写同一个TCP socket是线程安全的,因为TCP socket的读/写操作都上锁了。虽然线程安全,但依然不建议你这么做,因为TCP本身是基于数据流的协议,一份完整的消息数据可能会分开多次去写/读,内核的锁只保证单次读/写socket是线程安全,锁的粒度并不覆盖整个完整消息。因此建议用一个线程去读/写TCP socket
  2. 多线程并发读/写同一个UDP socket也是线程安全的,因为UDP socket的读/写操作也都上锁了。UDP写数据报的行为是”原子”的,不存在发一半包或收一半包的问题,要么整个包成功,要么整个包失败。因此多个线程同时读写,也就不会有TCP的问题。虽然如此,但还是建议用一个线程去读/写UDP socket。

代码执行send成功后,数据就发出去了吗?Socket编程概念

什么是 socket 缓冲区

编程的时候,如果要跟某个IP建立连接,我们需要调用操作系统提供的 socket API
socket 在操作系统层面,可以理解为一个文件。我们可以对这个文件进行一些方法操作

listen方法,可以让程序作为服务器监听其他客户端的连接。
connect,可以作为客户端连接服务器。
sendwrite可以发送数据,recvread可以接收数据。

在建立好连接之后,这个 socket 文件就像是远端机器的 “代理人” 一样。比如,如果我们想给远端服务发点什么东西,那就只需要对这个文件执行写操作就行了。Socket编程概念

那写到了这个文件之后,剩下的发送工作自然就是由操作系统内核来完成了。

既然是写给操作系统,那操作系统就需要提供一个地方给用户写。同理,接收消息也是一样。

这个地方就是 socket 缓冲区

用户发送消息的时候写给 send buffer(发送缓冲区)
用户接收消息的时候写给 recv buffer(接收缓冲区)

也就是说一个socket ,会带有两个缓冲区,一个用于发送,一个用于接收。因为这是个先进先出的结构,有时候也叫它们发送、接收队列Socket编程概念

怎么观察 socket 缓冲区

如果想要查看 socket 缓冲区,可以在linux环境下执行 netstat -nt 命令。

# netstat -nt Active Internet connections (w/o servers) Proto Recv-Q Send-Q Local Address Foreign Address State tcp 0 60 172.22.66.69:22 122.14.220.252:59889 ESTABLISHED

这上面表明了,这里有一个协议(Proto)类型为 TCP 的连接,同时还有本地(Local Address)和远端(Foreign Address)的IP信息,状态(State)是已连接。

还有Send-Q 是发送缓冲区,下面的数字60是指,当前还有60 Byte在发送缓冲区中未发送。而 Recv-Q 代表接收缓冲区, 此时是空的,数据都被应用进程接收干净了。

TCP部分

我们在使用TCP建立连接之后,一般会使用 send 发送数据。

int main(int argc, char *argv[]) { // 创建socket sockfd=socket(AF_INET,SOCK_STREAM, 0)) // 建立连接 connect(sockfd, 服务器ip信息, sizeof(server)) // 执行 send 发送消息 send(sockfd,str,sizeof(str),0)) // 关闭 socket close(sockfd); return 0; }

 上面是一段伪代码,仅用于展示大概逻辑,我们在建立好连接后,一般会在代码中执行 send 方法。那么此时,消息就会被立刻发到对端机器吗?

执行 send 发送的字节,会立马发送吗?

答案是不确定!执行 send 之后,数据只是拷贝到了socket 缓冲区。至 什么时候会发数据,发多少数据,全听操作系统安排。Socket编程概念

在用户进程中,程序通过操作 socket 会从用户态进入内核态,而 send方法会将数据一路传到传输层。在识别到是 TCP协议后,会调用 tcp_sendmsg 方法。

// net/ipv4/tcp.c // 以下省略了大量逻辑 int tcp_sendmsg() { // 如果还有可以放数据的空间 if (skb_availroom(skb) > 0) { // 尝试拷贝待发送数据到发送缓冲区 err = skb_add_data_nocache(sk, skb, from, copy); } // 下面是尝试发送的逻辑代码,先省略 }

在 tcp_sendmsg 中, 核心工作就是将待发送的数据组织按照先后顺序放入到发送缓冲区中, 然后根据实际情况(比如拥塞窗口等)判断是否要发数据。如果不发送数据,那么此时直接返回。 

如果缓冲区满了会怎么办

前面提到的情况里是,发送缓冲区有足够的空间,可以用于拷贝待发送数据。

如果发送缓冲区空间不足,或者满了,执行发送,会怎么样?

这里分两种情况。

首先,socket在创建的时候,是可以设置是阻塞的还是非阻塞的。

int s = socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, IPPROTO_TCP); 

比如通过上面的代码,就可以将 socket 设置为非阻塞 (SOCK_NONBLOCK)。

当发送缓冲区满了,如果还向socket执行send

  • 如果此时 socket 是阻塞的,那么程序会在那干等、死等,直到释放出新的缓存空间,就继续把数据拷进去,然后返回

Socket编程概念

  • 如果此时 socket 是非阻塞的,程序就会立刻返回一个 EAGAIN 错误信息,意思是  Try again , 现在缓冲区满了,你也别等了,待会再试一次。

Socket编程概念

我们可以简单看下源码是怎么实现的。还是回到刚才的 tcp_sendmsg 发送方法中。 

int tcp_sendmsg() { if (skb_availroom(skb) > 0) { // ..如果有足够缓冲区就执行balabla } else { // 如果发送缓冲区没空间了,那就等到有空间,至于等的方式,分阻塞和非阻塞 if ((err = sk_stream_wait_memory(sk, &timeo)) != 0) goto do_error; } }

里面提到的  sk_stream_wait_memory 会根据socket是否阻塞来决定是一直等等一会就返回。

int sk_stream_wait_memory(struct sock *sk, long *timeo_p) { while (1) { // 非阻塞模式时,会等到超时返回 EAGAIN if (等待超时)) return -EAGAIN; // 阻塞等待时,会等到发送缓冲区有足够的空间了,才跳出 if (sk_stream_memory_free(sk) && !vm_wait) break; } return err; }
如果接收缓冲区为空,执行 recv 会怎么样?

接收缓冲区也是类似的情况。

当接收缓冲区为空,如果还向socket执行 recv

  • 如果此时 socket 是阻塞的,那么程序会在那干等,直到接收缓冲区有数据,就会把数据从接收缓冲区拷贝到用户缓冲区,然后返回

Socket编程概念

  • 如果此时 socket 是非阻塞的,程序就会立刻返回一个 EAGAIN 错误信息。Socket编程概念

Socket编程概念

如果socket缓冲区还有数据,执行close了,会怎么样?

首先我们要知道,一般正常情况下,发送缓冲区和接收缓冲区 都应该是空的。

如果发送、接收缓冲区长时间非空,说明有数据堆积,这往往是由于一些网络问题或用户应用层问题,导致数据没有正常处理。

那么正常情况下,如果 socket 缓冲区为空,执行 close。就会触发四次挥手。Socket编程概念

这个也是面试老八股文内容了,这里我们只需要关注第一次挥手,发的是 FIN 就够了

如果接收缓冲区有数据时,执行close了,会怎么样?

socket close 时,主要的逻辑在 tcp_close() 里实现。

先说结论,关闭过程主要有两种情况:

  • 如果接收缓冲区还有数据未读,会先把接收缓冲区的数据清空,然后给对端发一个RST。
  • 如果接收缓冲区是空的,那么就调用 tcp_send_fin() 开始进行四次挥手过程的第一次挥手。
    void tcp_close(struct sock *sk, long timeout) { // 如果接收缓冲区有数据,那么清空数据 while ((skb = __skb_dequeue(&sk->sk_receive_queue)) != NULL) { u32 len = TCP_SKB_CB(skb)->end_seq - TCP_SKB_CB(skb)->seq - tcp_hdr(skb)->fin; data_was_unread += len; __kfree_skb(skb); } if (data_was_unread) { // 如果接收缓冲区的数据被清空了,发 RST tcp_send_active_reset(sk, sk->sk_allocation); } else if (tcp_close_state(sk)) { // 正常四次挥手, 发 FIN tcp_send_fin(sk); } // 等待关闭 sk_stream_wait_close(sk, timeout); }

    Socket编程概念

recvbuf非空 

如果发送缓冲区有数据时,执行close了,会怎么样?
void tcp_send_fin(struct sock *sk) { // 获得发送缓冲区的最后一块数据 struct sk_buff *skb, *tskb = tcp_write_queue_tail(sk); struct tcp_sock *tp = tcp_sk(sk); // 如果发送缓冲区还有数据 if (tskb && (tcp_send_head(sk) || sk_under_memory_pressure(sk))) { TCP_SKB_CB(tskb)->tcp_flags |= TCPHDR_FIN; // 把最后一块数据值为 FIN TCP_SKB_CB(tskb)->end_seq++; tp->write_seq++; } else { // 发送缓冲区没有数据,就造一个FIN包 } // 发送数据 __tcp_push_pending_frames(sk, tcp_current_mss(sk), TCP_NAGLE_OFF); }

此时,还有些数据没发出去,内核会把发送缓冲区最后一个数据块拿出来。然后置为 FIN。

socket 缓冲区是个先进先出的队列,这种情况是指内核会等待TCP层安静把发送缓冲区数据都发完,最后再执行 四次挥手的第一次挥手(FIN包)。

有一点需要注意的是,只有在接收缓冲区为空的前提下,我们才有可能走到 tcp_send_fin() 。而只有在进入了这个方法之后,我们才有可能考虑发送缓冲区是否为空的场景。Socket编程概念

sendbuf非空 

UDP部分

UDP也有缓冲区吗

说完TCP了,我们聊聊UDP。这对好基友,同时都是传输层里的重要协议。既然前面提到TCP有发送、接收缓冲区,那UDP有吗?

以前我以为。

“每个UDP socket都有一个接收缓冲区,没有发送缓冲区,从概念上来说就是只要有数据就发,不管对方是否可以正确接收,所以不缓冲,不需要发送缓冲区。”

后来我发现我错了。

UDP socket 也是 socket,一个socket 就是会有收和发两个缓冲区。跟用什么协议关系不大。

有没有是一回事,用不用又是一回事。

UDP不用发送缓冲区?

事实上,UDP不仅有发送缓冲区,也用发送缓冲区。一般正常情况下,会把数据直接拷到发送缓冲区后直接发送。还有一种情况,是在发送数据的时候,设置一个 MSG_MORE 的标记。

ssize_t send(int sock, const void *buf, size_t len, int flags); // flag 置为 MSG_MORE 

大概的意思是告诉内核,待会还有其他更多消息要一起发,先别着急发出去。此时内核就会把这份数据先用发送缓冲区缓存起来,待会应用层说ok了,再一起发。

我们可以看下源码。

int udp_sendmsg() { // corkreq 为 true 表示是 MSG_MORE 的方式,仅仅组织报文,不发送; int corkreq = up->corkflag || msg->msg_flags&MSG_MORE; // 将要发送的数据,按照MTU大小分割,每个片段一个skb;并且这些 // skb会放入到套接字的发送缓冲区中;该函数只是组织数据包,并不执行发送动作。 err = ip_append_data(sk, fl4, getfrag, msg->msg_iov, ulen, sizeof(struct udphdr), &ipc, &rt, corkreq ? msg->msg_flags|MSG_MORE : msg->msg_flags); // 没有启用 MSG_MORE 特性,那么直接将发送队列中的数据发送给IP。 if (!corkreq) err = udp_push_pending_frames(sk); }

因此,不管是不是 MSG_MORE, IP都会先把数据放到发送队列中,然后根据实际情况再考虑是不是立刻发送。

而我们大部分情况下,都不会用  MSG_MORE,也就是来一个数据包就直接发一个数据包。从这个行为上来说,虽然UDP用上了发送缓冲区,但实际上并没有起到”缓冲”的作用。

免责声明:本站所有文章内容,图片,视频等均是来源于用户投稿和互联网及文摘转载整编而成,不代表本站观点,不承担相关法律责任。其著作权各归其原作者或其出版社所有。如发现本站有涉嫌抄袭侵权/违法违规的内容,侵犯到您的权益,请在线联系站长,一经查实,本站将立刻删除。 本文来自网络,若有侵权,请联系删除,如若转载,请注明出处:https://haidsoft.com/112070.html

(0)
上一篇 2026-01-20 21:01
下一篇 2026-01-20 21:15

相关推荐

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

关注微信