什么是SOCKET
socket的英文原义是“孔”或“插座”。作为进程通信机制,取后一种意思。通常也称作“套接字”,用于描述IP地址和端口,是一个通信链的句柄(其实就是两个程序通信用的)。
socket非常类似于电话插座。以一个电话网为例:电话的通话双方相当于相互通信的2个程序,电话号码就是ip地址。任何用户在通话之前,首先要占有一部电话机,相当于申请一个socket;同时要知道对方的号码,相当于对方有一个固定的socket。然后向对方拨号呼叫,相当于发出连接请求。对方假如在场并空闲,拿起电话话筒,双方就可以正式通话,相当于连接成功。双方通话的过程,是一方向电话机发出信号和对方从电话机接收信号的过程,相当于向socket发送数据和从socket接收数据。通话结束后,一方挂起电话机相当于关闭socket,撤销连接。
套接字分类
为了满足不同程序对通信质量和性能的要求,一般的网络系统都提供了以下3种不同类型的套接字,以供用户在设计程序时根据不同需要来选择:
流式套接字(SOCK_STREAM):提供了一种可靠的、面向连接的双向数据传输服务。实现了数据无差错,无重复的发送,内设流量控制,被传输的数据被看做无记录边界的字节流。在TCP/IP协议簇中,使用TCP实现字节流的传输,当用户要发送大批量数据,或对数据传输的可靠性有较高要求时使用流式套接字。
数据报套接字(SOCK_DGRAM):提供了一种无连接、不可靠的双向数据传输服务。数据以独立的包形式被发送,并且保留了记录边界,不提供可靠性保证。数据在传输过程中可能会丢失或重复,并且不能保证在接收端数据按发送顺序接收。在TCP/IP协议簇中,使用UDP实现数据报套接字。
原始套接字(SOCK_RAW):该套接字允许对较低层协议(如IP或ICMP)进行直接访问。一般用于对TCP/IP核心协议的网络编程。 几乎没用过这个。
SOCKET相关概念
端口
在Internet上有很多这样的主机,这些主机一般运行了多个服务软件,同时提供几种服务。每种服务都打开一个Socket,并绑定到一个端口上,不同的端口对应于不同的服务(应用程序),因此,在网络协议中使用端口号识别主机上不同的进程。
例如:http使用80端口,FTP使用21端口。
可以认为是设备与外界通讯交流的出口。端口可分为虚拟端口和物理端口,其中虚拟端口指计算 机内部或交换机路由器内的端口,不可见。例如计算机中的80端口、21端口、23端口等。物理端口又称为接口,是可见端口,计算机背板的RJ45网口,交换机路由器集线器等RJ45端口。电话使用RJ11插口也属于物理端口的范畴。
规定一个设备有216个,也就是65536个端口,每个端口对应一个唯一的程序。每个网络程序,无论是客户端还是服务器端,都对应一个或多个特定的端口号。由于0-1024之间多被操作系统占用 ,所以实际编程时一般采用1024以后的端口号
端口详解
端口是指接口电路中的一些寄存器,这些寄存器分别用来存放数据信息、控制信息和状态信息,相应的端口分别称为数据端口、控制端口和状态端口。
电脑运行的系统程序,其实就像一个闭合的圆圈,但是电脑是为人服务的,他需要接受一些指令,并且要按照指令调整系统功能来工作,于是系统程序设计者,就把这个圆圈截成好多段,这些线段接口就叫端口(通俗讲是断口,就是中断),系统运行到这些端口时,一看端口是否打开或关闭,如果关闭,就是绳子接通了,系统往下运行,如果端口是打开的,系统就得到命令,有外部数据输入,接受外部数据并执行.
端口可分为虚拟端口和物理端口
“端口”是英文port的意译,可以认为是设备与外界通讯交流的出口。端口可分为虚拟端口和物理端口,其中虚拟端口指计算机内部或交换机路由器内的端口,不可见。例如计算机中的80端口、21端口、23端口等。物理端口又称为接口,是可见端口,计算机背板的RJ45网口,交换机路由器集线器等RJ45端口。电话使用RJ11插口也属于物理端口的范畴。
硬件端口
CPU通过接口寄存器或特定电路与外设进行数据传送,这些寄存器或特定电路称之为端口。
其中硬件领域的端口又称接口,如:并行端口、串行端口等。
网络端口
在网络技术中,端口(Port)有好几种意思。集线器、交换机、路由器的端口指的是连接其他网络设备的接口,如RJ-45端口、Serial端口等。我们 这里所指的端口不是指物理意义上的端口,而是特指TCP/IP协议中的端口,是逻辑意义上的端口。
软件端口 缓冲区。
TCP端口
TCP[1] :Transmission Control Protocol传输控制协议,TCP是一种面向连接(连接导向)的、可靠的、基于字节流的传输层(Transport layer)通信协议,由IETF的RFC 793说明(specified)。在简化的计算机网络OSI模型中,它完成第四层传输层所指定的功能,UDP是同一层内另一个重要的传输协议。
UDP端口
UDP[1] :User Datagram Protocol用户数据报协议,UDP是OSI参考模型中一种无连接的传输层协议,提供面向事务的简单不可靠信息传送服务。UDP 协议基本上是IP协议与上层协议的接口。UDP协议适用端口分别运行在同一台设备上的多个应用程序。
协议端口
如果把IP地址比作一间房子 ,端口就是出入这间房子的门。真正的房子只有几个门,但是一个IP地址的端口可以有65536(即:2^16)个之多!端口是通过端口号来标记的,端口号只有整数,范围是从0 到65535(2^16-1)。
在Internet上,各主机间通过TCP/IP协议发送和接收数据包,各个数据包根据其目的主机的ip地址来进行互联网络中的路由选择,把数据包顺利的传送到目的主机。大多数操作系统都支持多程序(进程)同时运行,那么目的主机应该把接收到的数据包传送给众多同时运行的进程中的哪一个呢?显然这个问题有待解决,端口机制便由此被引入进来。
本地操作系统会给那些有需求的进程分配协议端口(protocol port,即我们常说的端口),每个协议端口由一个正整数标识,如:80,139,445,等等。当目的主机接收到数据包后,将根据报文首部的目的端口号,把数据发送到相应端口,而与此端口相对应的那个进程将会领取数据并等待下一组数据的到来。说到这里,端口的概念似乎仍然抽象,那么继续跟我来,别走开。
端口其实就是队,操作系统为各个进程分配了不同的队,数据包按照目的端口被推入相应的队中,等待被进程取用,在极特殊的情况下,这个队也是有可能溢出的,不过操作系统允许各进程指定和调整自己的队的大小。
不光接受数据包的进程需要开启它自己的端口,发送数据包的进程也需要开启端口,这样,数据包中将会标识有源端口,以便接受方能顺利地回传数据包到这个端口。
端口详解
每种网络的服务功能都不相同,因此有必要将不同的封包送给不同的服务来处理,当你的主机同时开启了FTP与WWW服务时,别人送来的资料封包,就会依照 TCP 上面的 port 号码来给 FTP 这个服务或者是 WWW 这个服务来处理。
· 每一个 TCP 连接都必须由一端(通常为 client )发起请求,这个 port 通常是随机选择大于 1024 以上(因为0-1023一般被用作知名服务器的端口,被预定,如FTP、HTTP、SMTP等)的 port 号来进行!其 TCP封包会将(且只将) SYN旗标设定起来!这是整个联机的第一个封包;
· 如果另一端(通常为 Server ) 接受这个请求的话(特殊的服务需要以特殊的 port 来进行,例如 FTP 的 port 21 ),则会向请求端送回整个联机的第二个封包!其上除了 SYN旗标之外同时还将 ACK 旗标也设定起来,并同时在本机端建立资源以待联机之需;
· 然后,请求端获得服务端第一个响应封包之后,必须再响应对方一个确认封包,此时封包只带 ACK旗标(事实上,后继联机中的所有封包都必须带有 ACK 旗标);
· 只有当服务端收到请求端的确认( ACK )封包(也就是整个联机的第三个封包)之后,两端的联机才能正式建立。这就是所谓的 TCP 联机的’三次握手( Three-Way Handshake )’的原理。
经过三向交握之后,你的 client 端的 port 通常是高于 1024 的随机取得的 port,至于主机端则视当时的服务是开启哪一个 port 而定,例如 WWW 选择 80 而 FTP 则以 21 为正常的联机信道!
总而言之,我们这里所说的端口,不是计算机硬件的I/O端口,而是软件形式上的概念。根据提供服务类型的不同,端口分为两种,一种是TCP端口,一种是UDP端口。计算机之间相互通信的时候,分为两种方式:一种是发送信息以后,可以确认信息是否到达,也就是有应答的方式,这种方式大多采用TCP协议;一种是发送以后就不管了,不去确认信息是否到达,这种方式大多采用UDP协议。对应这两种协议的服务提供的端口,也就分为TCP端口和UDP端口。
那么,如果攻击者使用软件扫描目标计算机,得到目标计算机打开的端口,也就了解了目标计算机提供了哪些服务。我们都知道,提供服务就一定有服务软件的漏洞,根据这些,攻击者可以达到对目标计算机的初步了解。如果计算机的端口打开太多,而管理者不知道,那么,有两种情况:一种是提供了服务而管理者没有注意,比如安装IIS的时候,软件就会自动增加很多服务,而管理员可能没有注意到;一种是服务器被攻击者安装木马,通过特殊的端口进行通信。这两种情况都是很危险的,说到底,就是管理员不了解服务器提供的服务,减小了系统安全系数。
端口类型
TCP端口和UDP端口。由于TCP和UDP 两个协议是独立的,因此各自的端口号也相互独立,比如TCP有235端口,UDP也 可以有235端口,两者并不冲突。
1.周知端口(Well Known Ports)
周知端口是众所周知的端口号,范围从0到1023,其中80端口分配给WWW服务,21端口分配给FTP服务等。我们在IE的地址栏里输入一个网址的时候是不必指定端口号的,因为在默认情况下WWW服务的端口是“80”。
网络服务是可以使用其他端口号的,如果不是默认的端口号则应该在 地址栏上指定端口号,方法是在地址后面加上冒号“:”(半角),再加上端口号。比如使用“8080”作为WWW服务的端口,则需要在地址栏里输入“网址:8080”。
但是有些系统协议使用固定的端口号,它是不能被改变的,比如139 端口专门用于NetBIOS与TCP/IP之间的通信,不能手动改变。
2.动态端口(Dynamic Ports)
动态端口的范围是从49152到65535。之所以称为动态端口,是因为它 一般不固定分配某种服务,而是动态分配。
3.注册端口
端口1024到49151,分配给用户进程或应用程序。这些进程主要是用户选择安装的一些应用程序,而不是已经分配好了公认端口的常用程序。这些端口在没有被服务器资源占用的时候,可以用用户端动态选用为源端口。
端口作用
我们知道,一台拥有IP地址的主机可以提供许多服务,比如Web服务、FTP服务、SMTP服务等,这些服务完全可以通过1个IP地址来实现。那么,主机是怎样区分不同的网络服务呢?显然不能只靠IP地址,因为IP 地址与网络服务的关系是一对多的关系。实际上是通过“IP地址+端口号”来区分不同的服务的。
需要注意的是,端口并不是一一对应的。比如你的电脑作为客户机访 问一台WWW服务器时,WWW服务器使用“80”端口与你的电脑通信,但你的电脑则可能使用“3457”这样的端口。
动态端口(Dynamic Ports)
端口号。
端口在入侵中的作用
有人曾经把服务器比作房子,而把端口比作通向不同房间(服务)的门,如果不考虑细节的话,这是一个不错的比喻。入侵者要占领这间房子,势必要破门而入(物理入侵另说),那么对于入侵者来说,了解房子开了几扇门,都是什么样的门,门后面有什么东西就显得至关重要。
入侵者通常会用扫描器对目标主机的端口进行扫描,以确定哪些端口是开放的,从开放的端口,入侵者可以知道目标主机大致提供了哪些服务,进而猜测可能存在的漏洞,因此对端口的扫描可以帮助我们更好的了解目标主机,而对于管理员,扫描本机的开放端口也是做好安全防范的第一步。
认识网卡
网卡(Network Interface Card,简称NIC),也称网络适配器,是电脑与局域网相互连接的设备。无论是普通电脑还 是高端服务器,只要连接到局域网,就都需要安装一块网卡。如果有必要,一台电脑也可以同时安装两块或多块网卡。
一块网卡包括OSI 模型的两个层, 物理层和数据链路层:
1》物理层定义了数据传送与接收所需要的电与光信号、线路状态、时钟基准、数据编码和电路等, 并向数据链路层设备提供标准接口。
2》数据链路层则提供寻址机构、数据帧的构建、数据差错检查、传送控制、向网络层 提供标准的数据接口等功能。
二:网卡的组要作用
网卡的功能主要有两个:
一是将电脑的数据封装为帧,并通过网线(对无线网络来说就是电磁波)将数据发送到网络上去;
二是接收网络上其它设备传过来的帧,并将帧重新组合成数据,发送到所在的电脑中。
网卡能接收所有在网络上传输的 信号,但正常情况下只接受发送到该电脑的帧和广播帧,将其余的帧丢弃。然后,传送到系统CPU 做进一步处理。当电 脑发送数据时,网卡等待合适的时间将分组插入到数据流中。接收系统通知电脑消息是否完整地到达,如果出现问题, 将要求对方重新发送。
网络通信三要素:
- IP地址(网络上主机设备的唯一标识)
- 端口号(定位程序)
- 有效端口:0~65535,其中0~1024由系统使用,开发中一般使用1024以上端口.
- 传输协议(用什么样的方式进行交互)
- 常见协议:TCP(面向连接,提供可靠的服务),UDP(无连接,传输速度快)
IP地址
IP地址是一个规定,现在使用的是IPv4,既由4个0-255之间的数字组成,在计算机内部存储时只需要4个字节即可。在计算机中,IP地址是分配给网卡的,每个网卡有一个唯一的IP地址,如果一个计算机有多个网卡,则该台计算机则拥有多个不同的IP地址,在同一个网络内部,IP地址不能相同。IP地址的概念类似于电话号码、身份证这样的概念。
由于IP地址不方便记忆,所以有专门创造了 域名(Domain Name) 的概念,其实就是给IP取一个字符的名字,例如163.com、sina.com等。IP和域名之间存在一定的对应关系。如果把IP地址类比成身份证号的话,那么域名就是你的姓名。
其实在网络中只能使用IP地址进行数据传输,所以在传输以前,需要把域名转换为IP,这个由称作DNS的服务器专门来完成。
所以在网络编程中,可以使用IP或域名来标识网络上的一台设备。
协议
TCP:TCP是一种面向连接的、可靠的,基于字节流的传输层通信协议。为两台主机提供高可靠性的数据通信服务。它可以将源主机的数据无差错地传输到目标主机。当有数据要发送时,对应用进程送来的数据进行分片,以适合于在网络层中传输;当接收到网络层传来的分组时,它要对收到的分组进行确认,还要对丢失的分组设置超时重发等。为此TCP需要增加额外的许多开销,以便在数据传输过程中进行一些必要的控制,确保数据的可靠传输。因此,TCP传输的效率比较低。
TCP的工作过程
TCP是面向连接的协议,TCP协议通过三个报文段完成类似电话呼叫的连接建立过程,这个过程称为三次握手,如图所示:
第一次握手:建立连接时,客户端发送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连接,连接中的任何一方都能向对方发送数据和接收对方发来的数据。TCP协议负责把用户数据(字节流)按一定的格式和长度组成多个数据报进行发送,并在接收到数据报之后按分解顺序重新组装和恢复用户数据。
利用TCP传输数据时,数据是以字节流的形式进行传输的。
连接的终止
建立一个连接需要三次握手,而终止一个连接要经过四次握手,这是由TCP的半关闭(half-close)造成的。具体过程如图所示:
TCP的主要特点
TCP最主要的特点如下。
(1) 是面向连接的协议。
(2) 端到端的通信。每个TCP连接只能有两个端点,而且只能一对一通信,不能一点对多点直接通信。
(3) 高可靠性。通过TCP连接传送的数据,能保证数据无差错、不丢失、不重复地准确到达接收方,并且保证各数据到达的顺序与其发出的顺序相同。
(4) 全双工方式传输。
(5) 数据以字节流的方式传输。
(6) 传输的数据无消息边界。
同步与异步
同步工作方式是指利用TCP编写的程序执行到监听或接收语句时,在未完成工作(侦听到连接请求或收到对方发来的数据)前不再继续往下执行,线程处于阻塞状态,直到该语句完成相应的工作后才继续执行下一条语句。
异步工作方式是指程序执行到监听或接收语句时,不论工作是否完成,都会继续往下执行
UDP
UDP是一种简单的、面向数据报的无连接的协议,提供的是不一定可靠的传输服务。所谓“无连接”是指在正式通信前不必与对方先建立连接,不管对方状态如何都直接发送过去。这与发手机短信非常相似,只要知道对方的手机号就可以了,不要考虑对方手机处于什么状态。UDP虽然不能保证数据传输的可靠性,但数据传输的效率较高。
UDP与TCP的区别
(1) UDP可靠性不如TCP
TCP包含了专门的传递保证机制,当数据接收方收到发送方传来的信息时,会自动向发送方发出确认消息;发送方只有在接收到该确认消息之后才继续传送其他信息,否则将一直等待直到收到确认信息为止。与TCP不同,UDP并不提供数据传送的保证机制。如果在从发送方到接收方的传递过程中出现数据报的丢失,协议本身并不能做出任何检测或提示。因此,通常人们把UDP称为不可靠的传输协议。
(2) UDP不能保证有序传输
UDP不能确保数据的发送和接收顺序。对于突发性的数据报,有可能会乱序。
UDP的优势
(1) UDP速度比TCP快
由于UDP不需要先与对方建立连接,也不需要传输确认,因此其数据传输速度比TCP快得多。对于强调传输性能而不是传输完整性的应用(比如网络音频播放、视频点播和网络会议等),使用UDP比较合适,因为它的传输速度快,使通过网络播放的视频音质好、画面清晰。
(2) UDP有消息边界
发送方UDP对应用程序交下来的报文,在添加首部后就向下直接交付给IP层。既不拆分,也不合并,而是保留这些报文的边界。使用UDP不需要考虑消息边界问题,这样使得UDP编程相比TCP,在对接收到的数据的处理方面要方便的多。在程序员看来,UDP套接字使用比TCP简单。UDP的这一特征也说明了它是一种面向报文的传输协议。
(3) UDP可以一对多传输
由于传输数据不建立连接,也就不需要维护连接状态(包括收发状态等),因此一台服务器可以同时向多个客户端传输相同的消息。利用UDP可以使用广播或组播的方式同时向子网上的所有客户进程发送消息,这一点也比TCP方便。
其中,速度快是UDP的首要优势
由于TCP协议中植入了各种安全保障功能,在实际执行的过程中会占用大量的系统开销,无疑使速度受到严重影响。反观UDP,由于抛弃了信息可靠传输机制,将安全和排序等功能移交给上层应用完成,极大地降低了执行时间,使速度得到了保证。简而言之,UDP的“理念”就是“不顾一切,只为更快地发送数据”。
Socket与TCP/IP协议的关系
Socket是支持TCP/IP协议的网络通信的基本操作单元。数据链路层、网络层、传输层协议是在内核中实现的,因此操作系统需要实现一组系统调用,使得应用程序能够访问这些协议提供的服务,实现这组系统调用的API有socket。Socket是一套通用网络编程接口,它不但可以访问内核中TCP/IP协议栈,而且还可以访问其他网络协议栈(X.25协议栈、UNIX本地域协议栈等)。
socket与TCP/IP协议族的关系:
由socket定义的一组API提供两点功能:
一是将应用程序数据从用户缓冲区中复制到TCP/UDP内核发送缓冲区以交付内核来发送数据,或者是从内核TCP/UDP接收缓冲区中复制数据到用户缓冲区,以读取数据。
二是应用程序可以通过它们来修改内核中各层协议的某些头部信息或其他数据结构,从而精细地控制底层通信的行为。
socket一般应用模式:
端口
服务器端需要一直监听本地端口,看是否有客户端的连接请求,通信过程这里再简要说一下:
- 使用socket()创建用于监听的文件描述符listen_fd
- 使用bind()将上一步的文件描述符listen_fd与本地的IP和端口绑定
- 使用listen()监听listen_fd
- 使用accept()建立连接,返回用于通信的文件描述符correspond_fd
- 开始收发数据
- 关闭socket
在我们用bind绑定端口时,比如http监听80端口,https监听443端口,所以服务端在80端口监听想要建立http连接请求的客户端,在443端口监听https的连接请求。
多个http请求传到80端口,则会进入待连接队列,accept后,则会给各个连接创建进程,并分配一个socket用于通信。
端口号的作用是在网络连接中标识应用层的进程,服务端一般使用众所周知的端口号进行监听,而客户端的端口在连接时由客户端系统自动分配端口号。
一般来说,同一个端口只能被同一种协议下的某一个进程监听。
一个端口可以建立多个连接进程,多个不同的套接字可以拥有相同的目的端口号,这是由于各个客户端的IP和端口是不同的。
示例程序1:
根据socket通信基本流程图,总结通信的基本步骤:
服务器端:
第一步:创建一个用于监听连接的Socket对像;
第二步:用指定的端口号和服务器的ip建立一个EndPoint对像;
第三步:用socket对像的Bind()方法绑定EndPoint;
第四步:用socket对像的Listen()方法开始监听;
第五步:接收到客户端的连接,用socket对像的Accept()方法创建一个新的用于和客户端进行通信的socket对像;
第六步:通信结束后一定记得关闭socket;
客户端:
第一步:建立一个Socket对像;
第二步:用指定的端口号和服务器的ip建立一个EndPoint对像;
第三步:用socket对像的Connect()方法以上面建立的EndPoint对像做为参数,向服务器发出连接请求;
第四步:如果连接成功,就用socket对像的Send()方法向服务器发送信息;
第五步:用socket对像的Receive()方法接受服务器发来的信息 ;
第六步:通信结束后一定记得关闭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 }
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 }
代码测试:
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;
}
}
}
}
AF 定义: 地址族 - Address Family
WSA windows socket async
内核(kernel)利用文件描述符(file descriptor)来访问文件。文件描述符是非负整数。打开现存文件或新建文件时,内核会返回一个文件描述符。读写文件也需要使用文件描述符来指定待读写的文件。
示例程序2:
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;
}
}
心跳包机制
心跳机制是定时发送一个自定义的结构体(心跳包),让对方知道自己还活着,以确保连接的有效性的机制。(看下图)
网络中的接收和发送数据都是使用操作系统中的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 中的进程就是一个数据结构,看明白就可以理解文件描述符、重定向、管道命令的底层工作原理,最后我们从操作系统的角度看看为什么说线程和进程基本没有区别。
一、进程是什么
首先,抽象地来说,我们的计算机就是这个东西:
这个大的矩形表示计算机的内存空间,其中的小矩形代表进程,左下角的圆形表示磁盘,右下角的图形表示一些输入输出设备,比如鼠标键盘显示器等等。另外,注意到内存空间被划分为了两块,上半部分表示用户空间,下半部分表示内核空间。
用户空间装着用户进程需要使用的资源,比如你在程序代码里开一个数组,这个数组肯定存在用户空间;内核空间存放内核进程需要加载的系统资源,这一些资源一般是不允许用户访问的。但是注意有的用户进程会共享一些内核空间的资源,比如一些动态链接库等等。
我们用 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 是错误。
我们可以重新画一幅图:
明白了这个原理,输入重定向就很好理解了,程序想读取数据的时候就会去files[0]
读取,所以我们只要把files[0]
指向一个文件,那么程序就会从这个文件中读取数据,而不是从键盘:
$ command < file.txt
错误重定向也是一样的,就不再赘述。
管道符其实也是异曲同工,把一个进程的输出流和另一个进程的输入流接起一条「管道」,数据就在其中传递,不得不说这种设计思想真的很优美:
$ cmd1 | cmd2 | cmd3
到这里,你可能也看出「Linux 中一切皆文件」设计思路的高明了,不管是设备、另一个进程、socket 套接字还是真正的文件,全部都可以读写,统一装进一个简单的files
数组,进程通过简单的文件描述符访问相应资源,具体细节交于操作系统,有效解耦,优美高效。
三、线程是什么
首先要明确的是,多进程和多线程都是并发,都可以提高处理器的利用效率,所以现在的关键是,多线程和多进程有啥区别。
为什么说 Linux 中线程和进程基本没有区别呢,因为从 Linux 内核的角度来看,并没有把线程和进程区别对待。
我们知道系统调用fork()
可以新建一个子进程,函数pthread()
可以新建一个线程。但无论线程还是进程,都是用task_struct
结构表示的,唯一的区别就是共享的数据区域不同。换句话说,线程看起来跟进程没有区别,只是线程的某些数据区域和其父进程是共享的,而子进程是拷贝副本,而不是共享。就比如说,mm
结构和files
结构在线程中都是共享的,我画两张图你就明白了:
所以说,我们的多线程程序要利用锁机制,避免多个线程同时往同一区域写入数据,否则可能造成数据错乱。
那么你可能问,既然进程和线程差不多,而且多进程数据不共享,即不存在数据错乱的问题,为什么多线程的使用比多进程普遍得多呢?
因为现实中数据共享的并发更普遍呀,比如十个人同时从一个账户取十元,我们希望的是这个共享账户的余额正确减少一百元,而不是希望每人获得一个账户的拷贝,每个拷贝账户减少十元。
当然,必须要说明的是,只有 Linux 系统将线程看做共享数据的进程,不对其做特殊看待,其他的很多操作系统是对线程和进程区别对待的,线程有其特有的数据结构,我个人认为不如 Linux 的这种设计简洁,增加了系统的复杂度。
在 Linux 中新建线程和进程的效率都是很高的,对于新建进程时内存区域拷贝的问题,Linux 采用了 copy-on-write 的策略优化,也就是并不真正复制父进程的内存空间,而是等到需要写操作时才去复制。所以 Linux 中新建进程和新建线程都是很迅速的。
文件描述符
这里涉及到两个文件描述符:
- listen_fd
- correspond_fd
监听的文件描述符listen_fd只需要有一个,它只用于监听连接,而不参与收发数据。
用于通信的文件描述符correspond_fd负责和客户端进行通信,有多少个客户端建立了连接,就有多少个correspond_fd,与之对应的,客户端也有一个correspond_fd负责和服务端通信。
文件描述符对应两块内存结构:一个是读缓冲区,一个是写缓冲区
调用accept时,这个函数查看listen_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 表整个系统只有一个,它们三者之间的关系如下图所示。
- 进程级别的文件描述符表:内核为每个进程维护一个文件描述符表,该表记录了文件描述符的相关信息,包括文件描述符、指向打开文件表中记录的指针。
- 系统级别的打开文件表:内核对所有打开文件维护的一个进程共享的打开文件描述表,表中存储了处于打开状态文件的相关信息,包括文件类型、访问权限、文件操作函数(file_operations)等。
- 系统级别的 i-node 表:i-node 结构体记录了文件相关的信息,包括文件长度,文件所在设备,文件物理位置,创建、修改和更新时间等,"ls -i" 命令可以查看文件 i-node 节点
从本质上讲,这三种表都是结构体数组,0、1、2、73、1976 等都是数组下标。表头只是我自己添加的注释,数组本身是没有的。实线箭头表示指针的指向,虚线箭头是我自己添加的注释。
你看,文件描述符只不过是一个数组下标吗!
通过文件描述符,可以找到文件指针,从而进入打开文件表。该表存储了以下信息:
- 文件偏移量,也就是文件内部指针偏移量。调用 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 问题
前面提到的 TCP Socket 调用流程是最简单、最基本的,它基本只能一对一通信,因为使用的是同步阻塞的方式,当服务端在还没处理完一个客户端的网络 I/O 时,或者 读写操作发生阻塞时,其他客户端是无法与服务端连接的。
可如果我们服务器只能服务一个客户,那这样就太浪费资源了,于是我们要改进这个网络 I/O 模型,以支持更多的客户端。
在改进网络 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」。
下面这张图描述了从连接请求到连接建立,父进程创建生子进程为客户服务。
另外,当「子进程」退出时,实际上内核里还会保留该进程的一些信息,也是会占用内存的,如果不做好“回收”工作,就会变成僵尸进程,随着僵尸进程越多,会慢慢耗尽我们的系统资源。
因此,父进程要“善后”好自己的孩子,怎么善后呢?那么有两种方式可以在子进程退出后回收资源,分别是调用 wait()
和 waitpid()
函数。
这种用多个进程来应付多个客户端的方式,在应对 100 个客户端还是可行的,但是当客户端数量高达一万时,肯定扛不住的,因为每产生一个进程,必会占据一定的系统资源,而且进程间上下文切换的“包袱”是很重的,性能会大打折扣。
进程的上下文切换不仅包含了虚拟内存、栈、全局变量等用户空间的资源,还包括了内核堆栈、寄存器等内核空间的资源。
多线程模型
既然进程间上下文切换的“包袱”很重,那我们就搞个比较轻量级的模型来应对多用户的请求 —— 多线程模型。
线程是运行在进程中的一个“逻辑流”,单进程中可以运行多个线程,同进程里的线程可以共享进程的部分资源,比如文件描述符列表、进程空间、代码、全局数据、堆、共享库等,这些共享些资源在上下文切换时不需要切换,而只需要切换线程的私有数据、寄存器等不共享的数据,因此同一个进程下的线程上下文切换的开销要比进程小得多。
当服务器与客户端 TCP 完成连接后,通过 pthread_create()
函数创建线程,然后将「已连接 Socket」的文件描述符传递给线程函数,接着在线程里和客户端进行通信,从而达到并发处理的目的。
如果每来一个连接就创建一个线程,线程运行完后,还得操作系统还得销毁线程,虽说线程切换的上写文开销不大,但是如果频繁创建和销毁线程,系统开销也是不小的。
那么,我们可以使用线程池的方式来避免线程的频繁创建和销毁,所谓的线程池,就是提前创建若干个线程,这样当由新连接建立时,将这个已连接的 Socket 放入到一个队列里,然后线程池里的线程负责从队列中取出「已连接 Socket 」进行处理。
需要注意的是,这个队列是全局的,每个线程都会操作,为了避免多线程竞争,线程在操作这个队列前要加锁。
上面基于进程或者线程模型的,其实还是有问题的。新到来一个 TCP 连接,就需要分配一个进程或者线程,那么如果要达到 C10K,意味着要一台机器维护 1 万个连接,相当于要维护 1 万个进程/线程,操作系统就算死扛也是扛不住的。
I/O 多路复用
既然为每个请求分配一个进程/线程的方式不合适,那有没有可能只使用一个进程来维护多个 Socket 呢?答案是有的,那就是 I/O 多路复用技术。
一个进程虽然任一时刻只能处理一个请求,但是处理每个请求的事件时,耗时控制在 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 相关的接口作用:
epoll 的方式即使监听的 Socket 数量越多的时候,效率不会大幅度降低,能够同时监听的 Socket 的数目也非常的多了,上限就为系统定义的进程打开的最大文件描述符个数。因而,epoll 被称为解决 C10K 问题的利器。
插个题外话,网上文章不少说,epoll_wait
返回时,对于就绪的事件,epoll 使用的是共享内存的方式,即用户态和内核态都指向了就绪链表,所以就避免了内存拷贝消耗。
这是错的!看过 epoll 内核源码的都知道,压根就没有使用共享内存这个玩意。你可以从下面这份代码看到, epoll_wait 实现的内核代码中调用了 __put_user
函数,这个函数就是将数据从内核拷贝到用户空间。
好了,这个题外话就说到这了,我们继续!
边缘触发和水平触发
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 被创建后,都会分配两个缓冲区,输入缓冲区和输出缓冲区。
write()/send() 并不立即向网络中传输数据,而是先将数据写入缓冲区中,再由TCP协议将数据从缓冲区发送到目标机器。一旦将数据写入到缓冲区,函数就可以成功返回,不管它们有没有到达目标机器,也不管它们何时被发送到网络,这些都是TCP协议负责的事情。
TCP协议独立于 write()/send() 函数,数据有可能刚被写入缓冲区就发送到网络,也可能在缓冲区中不断积压,多次写入的数据被一次性发送到网络,这取决于当时的网络情况、当前线程是否空闲等诸多因素,不由程序员控制。
read()/recv() 函数也是如此,也从输入缓冲区中读取数据,而不是直接从网络中读取。
图:TCP套接字的I/O缓冲区示意图
这些I/O缓冲区特性可整理如下:
- 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);
运行结果:
Buffer length: 8192
TCP套接字阻塞模式
对于TCP套接字(默认情况下),当使用 write()/send() 发送数据时:
1) 首先会检查缓冲区,如果缓冲区的可用空间长度小于<要发送的数据,那么 write()/send() 会被阻塞(暂停执行),直到缓冲区中的数据被发送到目标机器,腾出足够的空间,才唤醒 write()/send() 函数继续写入数据。
2) 如果TCP协议正在向网络发送数据,那么输出缓冲区会被锁定,不允许写入,write()/send() 也会被阻塞,直到数据发送完毕缓冲区解锁,write()/send() 才会被唤醒。
3) 如果要写入的数据大于>缓冲区的最大长度,那么将分批写入。
4) 直到所有数据被写入缓冲区 write()/send() 才能返回。
当使用 read()/recv() 读取数据时:
1) 首先会检查缓冲区,如果缓冲区中有数据,那么就读取,否则函数会被阻塞,直到网络上有数据到来。
2) 如果要读取的数据长度小于<缓冲区中的数据长度,那么就不能一次性将缓冲区中的所有数据读出,剩余数据将不断积压,直到有 read()/recv() 函数再次读取。
3) 直到读取到数据后 read()/recv() 函数才会返回,否则就一直被阻塞。
这就是TCP套接字的阻塞模式。所谓阻塞,就是上一步动作没有完成,下一步动作将暂停,直到上一步动作完成后才能继续,以保持同步性。
TCP套接字默认情况下是阻塞模式,也是最常用的。当然也可以更改为非阻塞模式
socket是并发安全的??
为了更好的聊今天的话题,我们先假设一个场景。
我相信我读者大部分都是做互联网应用开发的,可能对游戏的架构不太了解。
我们想象中的游戏架构是下面这样的。
也就是用户客户端直接连接游戏核心逻辑服务器,下面简称GameServer。GameServer主要负责实现各种玩法逻辑。
这当然是能跑起来,实现也很简单。但这样会有个问题,因为游戏这块蛋糕很大,所以总会遇到很多挺刑的事情。如果让用户直连GameServer,那相当于把GameServer的ip暴露给了所有人。
不赚钱还好,一旦游戏赚钱,就会遇到各种攻击。
你猜《羊了个羊》最火的时候为啥老是崩溃?
假设一个游戏服务器能承载4k玩家,一旦服务器遭受直接攻击,那4k玩家都会被影响。
所以很多时候不会让用户直连GameServer。
而是在前面加入一层网关层,下面简称gateway。类似这样。
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是并发安全的吗?能让这多个线程同时并发写吗?
写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的发送缓冲区中,此时并没有所谓的发送数据出去,函数就返回了,内核后续再根据实际情况异步发送。关于这点,下面更详细的介绍。
从tcp_sendmsg
的代码中可以看到,在对socket的缓冲区执行写操作的时候,linux内核已经自动帮我们加好了锁,也就是说,是线程安全的。
所以可以多线程不加锁并发写入数据吗?
不能。
问题的关键在于锁的粒度。
但我们知道TCP有三大特点,面向连接,可靠的,基于字节流的协议。
问题就出在这个"基于字节流",它是个源源不断的二进制数据流,无边界。来多少就发多少,但是能发多少,得看你的发送缓冲区还剩多少空间。
举个例子,假设A线程想发123
数据包,B线程想发456
数据包。
A和B线程同时执行send()
,A先抢到锁,此时发送缓冲区就剩1个
数据包的位置,那发了"1"
,然后发送缓冲区满了,A线程退出(非阻塞),当发送缓冲区腾出位置后,此时AB再次同时争抢,这次被B先抢到了,B发了"4"
之后缓冲区又满了,不得不退出。
重复这样多次争抢之后,原本的数据内容都被打乱了,变成了142356
。因为数据123是个整体
,456又是个整体
,像现在这样数据被打乱的话,接收方就算收到了数据也没办法正常解析。
也就是说锁的粒度其实是每次"写操作",但每次写操作并不保证能把消息写完整。
那么问题就来了,那是不是我在写整个完整消息之前加个锁,整个消息都写完之后再解锁,这样就好了?
类似下面这样。
// 伪代码
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。
于是上面的场景就变成了下面这样。
于是在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的只能有一个线程,读到了消息之后塞到加锁队列中,再将消息分开给到GameServer的多线程用户逻辑模块中去做处理。
读写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呢?它是基于数据报的协议。
基于数据流和基于数据报有什么区别呢?
基于数据流,意味着发给内核底层的数据就跟水进入水管一样,内核根本不知道什么时候是个头,没有明确的边界。
而基于数据报,可以类比为一件件快递进入传送管道一样,内核很清楚拿到的是几件快递,快递和快递之间边界分明。
那从我们使用的方式来看,应用层通过TCP去发数据,TCP就先把它放到缓冲区中,然后就返回。至于什么时候发数据,发多少数据,发的数据是刚刚应用层传进去的一半还是全部都是不确定的,全看内核的心情。在接收端收的时候也一样。
但UDP就不同,UDP 对应用层交下来的报文,既不合并,也不拆分,而是保留这些报文的边界。
无论应用层交给 UDP 多长的报文,UDP 都照样发送,即一次发送一个报文。至于数据包太长,需要分片,那也是IP层的事情,跟UDP没啥关系,大不了效率低一些。而接收方在接收数据报的时候,一次取一个完整的包,不存在TCP常见的半包和粘包问题。
正因为基于数据报和基于字节流的差异,TCP 发送端发 10 次字节流数据,接收端可以分 100 次去取数据,每次取数据的长度可以根据处理能力作调整;但 UDP 发送端发了 10 次数据报,那接收端就要在 10 次收完,且发了多少次,就取多少次,确保每次都是一个完整的数据报。
所以从这个角度来说,UDP写数据报的行为是"原子"的,不存在发一半包或收一半包的问题,要么整个包成功,要么整个包失败。因此多个线程同时读写,也就不会有TCP的问题。
所以,可以多个线程同时读写同一个udp socket。
但就算可以,我依然不建议大家这么做。
为什么不建议使用多线程同时读写同一个UDP socket
udp本身是不可靠的协议,多线程高并发执行发送时,会对系统造成较大压力,这时候丢包是常见的事情。虽然这时候应用层能实现重传逻辑,但重传这件事毕竟是越少越好。因此通常还会希望能有个应用层流量控制的功能,如果是单线程读写的话,就可以在同一个地方对流量实现调控。类似的,实现其他插件功能也会更加方便,比如给某些vip等级的老板更快速的游戏体验啥的(我瞎说的)。
所以正确的做法,还是跟TCP一样,不管外面有多少个线程,还是并发加锁写到一个队列里,然后起一个单独的线程去做发送操作。
总结
- 多线程并发读/写同一个TCP socket是线程安全的,因为TCP socket的读/写操作都上锁了。虽然线程安全,但依然不建议你这么做,因为TCP本身是基于数据流的协议,一份完整的消息数据可能会分开多次去写/读,内核的锁只保证单次读/写socket是线程安全,锁的粒度并不覆盖整个完整消息。因此建议用一个线程去读/写TCP socket。
- 多线程并发读/写同一个UDP socket也是线程安全的,因为UDP socket的读/写操作也都上锁了。UDP写数据报的行为是"原子"的,不存在发一半包或收一半包的问题,要么整个包成功,要么整个包失败。因此多个线程同时读写,也就不会有TCP的问题。虽然如此,但还是建议用一个线程去读/写UDP socket。
代码执行send成功后,数据就发出去了吗?
什么是 socket 缓冲区
编程的时候,如果要跟某个IP建立连接,我们需要调用操作系统提供的 socket API
。
socket 在操作系统层面,可以理解为一个文件。我们可以对这个文件进行一些方法操作。
用listen
方法,可以让程序作为服务器监听其他客户端的连接。
用connect
,可以作为客户端连接服务器。
用send
或write
可以发送数据,recv
或read
可以接收数据。
在建立好连接之后,这个 socket 文件就像是远端机器的 "代理人" 一样。比如,如果我们想给远端服务发点什么东西,那就只需要对这个文件执行写操作就行了。
那写到了这个文件之后,剩下的发送工作自然就是由操作系统内核来完成了。
既然是写给操作系统,那操作系统就需要提供一个地方给用户写。同理,接收消息也是一样。
这个地方就是 socket 缓冲区。
用户发送消息的时候写给 send buffer(发送缓冲区)
用户接收消息的时候写给 recv buffer(接收缓冲区)
也就是说一个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 会从用户态进入内核态,而 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 是非阻塞的,程序就会立刻返回一个
EAGAIN
错误信息,意思是Try again
, 现在缓冲区满了,你也别等了,待会再试一次。
我们可以简单看下源码是怎么实现的。还是回到刚才的 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 是非阻塞的,程序就会立刻返回一个
EAGAIN
错误信息。
如果socket缓冲区还有数据,执行close了,会怎么样?
首先我们要知道,一般正常情况下,发送缓冲区和接收缓冲区 都应该是空的。
如果发送、接收缓冲区长时间非空,说明有数据堆积,这往往是由于一些网络问题或用户应用层问题,导致数据没有正常处理。
那么正常情况下,如果 socket
缓冲区为空,执行 close
。就会触发四次挥手。
这个也是面试老八股文内容了,这里我们只需要关注第一次挥手,发的是 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); }
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()
。而只有在进入了这个方法之后,我们才有可能考虑发送缓冲区是否为空的场景。
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用上了发送缓冲区,但实际上并没有起到"缓冲"的作用。