文章目录
一、互联网协议概述
1. 认识网络协议
协议就是一种 “约定”,这种“约定”使那些由不同厂商的设备,不同CPU及不同操作系统组成的计算机之间,只要遵循相同的协议就可以实现通信。它是多方协商出来的一种通信方案,达成一种“共识”。网络协议是网络上所有设备(网络服务器、计算机和交换机、路由器、防火墙等)之间的通信规则的集合。它定义了信息在交流时必须采用的格式以及这些格式的含义。大多数网络采用分层结构,每一层都建立在它的下层,为它的上层提供一定的服务。
网络协议为计算机网络中进行数据交换而建立的规则、标准或约定的集合。例如,网络中一个微机用户和一个大型主机的操作员进行通信,由于这两个数据终端所用字符集不同,因此操作员所输入的命令彼此不认识。为了能进行通信,规定每个终端都要将各自字符集中的字符先变换为标准字符集的字符后,才进入网络传送,到达目的终端之后,再变换为该终端字符集的字符。
一般来说,网络协议是网络之间的桥梁,只有具有相同网络协议的计算机才能进行通信和信息交换。这就像人际交往中使用的各种语言一样。只有使用同一种语言,我们才能正常顺畅地交流。从专业的角度来看,定义网络协议是计算机在网络中实现通信时必须遵守的协议,即通信协议。主要规定信息传输速率、传输码、码结构、传输控制步骤、差错控制等,并制定标准。
协议的好处:
- 统一标准,通信双方能够通过某种标识把数据识别出来
- 提高通信的效率和可靠性
计算机之间想要传递各种不同的信息,就需要约定好双方的数据格式。
2. 协议分层
为了简化网络设计的复杂性,网络协议采用分层的结构,各层协议之间即相互独立又可以进行相互协调的高效工作。而整个网络协议栈被分成层状结构的一个个的小模块,具体到实例有OSI七层参考模型
和TCP/IP五层模型
每一层做不同的工作,下一层为上一层提供特定的服务,同一层之间交互使用相同的“协议”。
协议分层的好处:
- 各层之间是独立的(解耦)。某一层并不需要知道它的下一层是如何实现的,而仅仅需要知道该层通过层间的接口所提供的服务。层与层之间通过接口实现通信,实现了层与层之间的“解耦”。也就是说上一层的工作如何进行并不影响下一层的工作,这样我们在进行每一层的工作设计时只要保证接口不变可以随意调整层内的工作方式。
- 灵活性好。当任何一层发生变化时,只要层间接口关系保持不变,则在这层以上或以下各层均不受影响。当某一层出现技术革新或者某一层在工作中出现问题时不会连累到其他层的工作,排除问题时也只需要考虑这一层单独的问题即可。
- 结构上可分割开。各层都可以采用最合适的技术来实现。技术的发展往往是不对称的,层次化的划分有效避免了木桶效应,不会因为某一方面技术的不完善而影响整体的工作效率。
- 易于实现和维护。这种结构使得实现和调试一个庞大又复杂的系统变得易于处理,因为整个的系统已被分解为若干个相对独立的子系统。进行调试和维护时,可以对每一层进行单独的调试,避免了出现找不到问题、解决错问题的情况。
举例: 两个人在打电话,都是用汉语进行交流,表面上看是两人直接进行通信。仔细思考会发现,在人通信层的下一层在为两人通信提供服务,电话层与对端的电话层通过电话协议进行通信,电话层需要将人说话的声音进行处理,转为电信号,然后发送给对端的电话层,对端的电话层收到电信号之后进行处理,转为人的声音,这样,对端的人就听到的是人的声音。两个人通信的时候是不会关心下一层服务细节,就可以直接进行通信。
协议分层的本质: 协议本质是一种软件,协议分层实际上就是一种软件分层,而软件实际上是代码和数据,所以软件分层需要完成代码之间的分层和数据之间的分层,部分数据同时需要在不同层之间进行流动。
3. OSI七层模型
OSI(Open System Interconnection,开放系统互联)七层网络模型,是参考模型是国际标准化组织(ISO)制定的一个用于计算机或通信系统间互联的标准体系。OSI模型定义了网络互连的七层框架(物理层、数据链路层、网络层、传输层、会话层、表示层、应用层),每一层实现各自的功能和协议,并完成与相邻层的接口通信
- 把网络从逻辑上分为了7层. 每一层都有相关、相对应的物理设备,比如路由器,交换机
- OSI 七层模型是一种框架性的设计方法,其最主要的功能使就是帮助不同类型的主机实现数据传输
- 它的最大优点是将服务、接口和协议这三个概念明确地区分开来,概念清楚,理论也比较完整. 通过七个层次化的结构模型使不同的系统不同的网络之间实现可靠的通讯
七层模型从下到上依次是:
1. 物理层
物理媒体的基础上,规定物理设备标准:如网线的接口类型、光纤的接口类型、传输介质的传输速率等。1、0转化为电流强弱来进行传输,到达目的地后在转化为1、0(就是数模转换和模数转换)。
规定了激活、维持、关闭通信端点之间的机械特性、电气特性、功能特性以及过程特性;该层为上层协议提供了一个传输数据的物理媒体,这一层的数据叫做比特
2. 数据链路层
负责物理层面上的互联的、相邻节点间的通信传输(例如一个以太网项链的2个节点之间的通信);该层有建立逻辑连接、进行硬件地址寻址、差错校验等功能。
一串无意义的二进制串是无意义的,数据链路层就是来对电信号来做分组
的,将比特位组合为字节进而组合成“帧”
。采用了”帧”的数据块进行传输
,为了确保数据通信的准确,实现数据有效的差错控制,加入了检错等功能
。这一层的数据称为数据帧
3. 网络层
进行逻辑地址寻址,实现不同网络之间的路径选择。就是引进
了一套新的地址
,使我们能区分不同计算机
是否属于同一个子网
,进而进行路径选择
,从而实现“非相邻节点”间的通信传输(例如俩个不同局域网中2个节点之间的通信)
这套地址就称为“网络地址/IP地址”
。(协议有:ICMP IGMP IP(IPV4 IPV6) ARP RARP)这一层的数据称为数据包
4. 传输层
定义传输数据的协议端口号,以及流量控制和差错校验。网络层可以建立"主机到主机"
的通信,而传输层是为了确定该通信数据是提供给该主机上的哪一个进程,所以我们还是需要一个参数,即“端口”
。传输层就是提供“端口到端口”
的通信服务。协议有:TCP UDP。这一层的数据称为数据段
5. 会话层
建立、管理、终止会话。对应主机进程,指本地主机与远程主机正在进行的会话。即负责建立和断开通信连接(数据流动的逻辑通路),记忆数据的分隔等数据传输相关的管理
6. 表示层
数据的表示、安全、压缩。格式有,JPEG、ASCll、DECOIC、加密格式等。即将应用处理的信息转换为适合网络传输的格式
,或将来自下一层的数据转换为上层能够处理的格式;主要负责数据格式的转换
,确保一个系统的应用层信息可被另一个系统应用层读取。
具体来说,就是将设备固有的数据格式转换为网络标准传输格式,不同设备对同一比特流解释的结果可能会不同;因此,主要负责使它们保持一致
7. 应用层
是其他层对用户的已经封装好的接口,提供多种服务,用户只需操作应用层就可以得到服务内容,这样封装可以让更多的人能使用它。包含的主要协议:FTP(文件传送协议)、Telnet(远程登录协议)、DNS(域名解析协议)、SMTP(邮件传送协议),POP3协议(邮局协议),HTTP协议(Hyper Text Transfer Protocol)
4. TCP/IP五层(或四层)模型
OSI七层模型既复杂又不使用,所以就有了TCP/IP五层(或四层)模型。
TCP/IP(Transmission Control Protocol/Internet Protocol,传输控制协议/网际协议)是指能够在多个不同网络间实现信息传输的协议簇
。TCP/IP协议
不仅仅指的是TCP 和IP两个协议,而是指一个由FTP、SMTP、TCP、UDP、IP等协议构成的协议簇
, 只是因为在TCP/IP协议中TCP协议和IP协议最具代表性,所以被称为TCP/IP协议。
TCP/IP通讯协议采用了5层的层级结构,每一层都呼叫它的下一层所提供的网络来完成自己的需求。物理层有时候不做讨论,所以有时候也叫四层模型。
- 物理层:负责
光电信号的传递方式
,比如现在以太网同用的网线(双绞线),早期以太网采用同轴电缆,而光纤,WIFI等都属于物理层的概念。物理层的能力决定了最大传输速、传输距离,抗干扰性等,集线器(Hub)工作在物理层 - 数据链路层:负责设备之间的
数据帧的传送和识别
,·例如网卡设备的驱动,帧同步(就是说从网线上检测到什么信号算作新帧的开始),冲突检测(如果检测到冲突就自动重发),数据差错校验等工作,有以太网,令牌环网,无线LAN等标准,交换机(Switch)工作在数据链路层 - 网络层:负责
地址管理和路由选择
,例如在IP协议中,通过IP地址来表示一台主机,并通过路由表的方式规划出两台主机之间的数据传输的线路(路由),路由器工作在网络层 - 传输层:解决了诸如
端到端可靠性
问题,能确保数据可靠的到达目的地
,甚至能保证数据按照正确的顺序到达目的地
。传输层的主要功能大致如下:(1)为端到端连接提供传输服务;(2)这种传输服务分为可靠和不可靠的,其中TCP是典型的可靠传输,而UDP则是不可靠传输;(3)为端到端连接提供流量控制、差错控制、QoS(Quality ofService)服务质量等管理服务。传输层主要有两个性质不同的协议:TCP传输控制协议和UDP用户数据报协议。 - 应用层:负责应用程序间够用,如简单电子邮件传输(SMTP),文件传输协议(FTP),网络远程访问协议(Telent)等,网络编程主要针对应用层
可以看出的是,OS贯穿整个网络协议栈,协议栈是网络标准组织定义的,所有的OS(Windows.Linux和MacOs等)都是支持的。数据通信的本质就是两个协议栈之间进行通信。
总结:
- 应用层解决的传输数据的目的,根据特定的通信目的,进行数据分析与处理,达到某种业务性的目的
- 传输层和网络层处理数据传输遇到的问题,保证数据的可靠性
- 数据链路层和物理层负责数据真正发送的过程,完成以太网和局域网的通信
- 下三成处理的是
通信细节
,应用层处理的是业务细节
5. 数据的封装和分用
- 不同的协议层对
数据包
有不同的称谓,在传输层叫做段(segment)
,在网络层叫做数据报 (datagram)
,在链路层叫做数据帧(frame)
,应用层叫做请求和响应
- 应用层数据通过协议栈发到网络上时,每层协议都要对数据加上一个数据首部,该数据被称为
有效载荷
,首部被称为包头
,该过程被称为封装,分装后的数据被称为“数据包”
包头
中包含了一些类似于首部有多长, 有效载荷有多长, 上层协议是什么等信息,数据封装成帧后发到传输介质上,到达目的主机后每层协议再剥掉相应的首部,根据首部中的 “上层协议字段” 将数据交给对应的上层协议处理,这称为分用
思考几个问题:
1. 每一协议层的数据包是什么?
数据包=报头+有效载荷
2. 协议的共性是什么?
(1)如何将数据包中的报头和有效载荷分离的问题,这个过程叫做解包。
(2)自底向上,要确认自己的有效载荷交付给上层的那个协议,这个过程叫做分用。
3. 如何解决将数据包中的报头和有效载荷分离的问题?
(1)定长报头。报头的长度是确定的,这样就可以实现二者分离。
(2)自定义描述符字段。报头中添加一个字段,表示报头的长度。
数据分装过程
数据分用过程
6. 局域网通信
当源主机发出数据时,数据在源主机中从上层向下层传送;目的主机拿到数据后,将数据从下层向上层传递
局域网通讯过程
数据在自身协议栈自顶向下进行封装:
- 数据包交给应用层,应用层添加上对应的应用层协议报头,然后把整个数据包向下交付给传输层
- 传输层再添加上对应的传输层协议报头,然后把整个数据包向下交付给网络层
- 网络层再添加上对应的网络层协议报头,然后把整个数据包向下交付给数据链路层
- 数据链路层再添加上对应的数据链路层协议报头,然后把整个数据包通过网络交付给对端数据链路层
数据在对端协议栈自底向上进行分用:
- 数据链路层将数据包的报头和有效载荷进行解包分离,然后将有效载荷交付给上层的网络层
- 网络层将数据包的报头和有效载荷进行解包分离,然后将有效载荷交付给上层的传输层
- 传输层将数据包的报头和有效载荷进行解包分离,然后将有效载荷交付给上层的应用层
- 应用层将数据包的报头和有效载荷进行解包分离,将最后的数据进行相关处理然后交付给用户
局域网通讯原理
1. MAC地址
局域网中,它们是如何确定数据是发给哪一台主机?回答是数据链路层,有一个MAC地址(48位),网卡硬件地址或者序列号,是全球唯一的,用来标识主机的唯一性。每一台主机都要一个MAC地址
,且都知道,发送方将数据发出去,这个数据
里面包含目标主机的MAC地址信息
。
2. 广播
定义地址只是第一步,后面还有更多的步骤。首先,一块网卡怎么会知道另一块网卡的MAC地址?回答是有一种ARP协议
,可以解决这个问题。这个留到后面介绍,这里只需要知道,以太网数据包必须知道接收方的MAC地址,然后才能发送
。
有了MAC地址,怎样才能把数据包准确送到接收方? 回答是以太网不是把数据包准确送到接收方,而是向本网络内所有计算机发送,每个主机
都可以收到这一份数据
,且用自己的MAC地址与数据中的目标主机MAC地址进行比较
,如果不同
,表明该数据不是发给自己的,就将数据丢掉
,相同
就表明该数据是发给自己的,就收下
。这种发送方式就叫做"广播"(broadcasting)
上图中,1号计算机向2号计算机发送一个数据包,同一个子网络的3号、4号、5号计算机都会收到这个包。它们读取这个包的"标头",找到接收方的MAC地址,然后与自身的MAC地址相比较,如果两者相同,就接受这个包,做进一步处理,否则就丢弃这个包
有了数据包的定义、网卡的MAC地址、广播的发送方式,"链接层"就可以在多台计算机之间传送数据了
3. 局域网通讯原理
局域网中,有多台主机直接通信,有可能会发生数据碰撞
,这样就会影响其它主机间的通信,所以一个局域网可以看作是一个碰撞域
。
碰撞后的数据就是垃圾数据量
,局域网中的所有主机都可以收到发出去的数据,包括发数据的主机本身也是如此,该主机会将收到的数据
和此前发出去的数据
进行对比
,如果不同
,代表此前发出去的数据发生了碰撞
,这就是碰撞检测
数据发生碰撞后,发送方不会立即将数据进行重写发送,而是等一段数据,在重新发生,这就是碰撞避免算法
,也是碰撞避免的一种机制。
所以说,局域网的数据通信时在一个碰撞域中不断地碰撞,然后进行碰撞检测,碰撞避免。局域网通信的本质就是基于碰撞域、碰撞检测和碰见避免实现通信。局域网内主机越多,碰撞几率越多,交换机在局域网中的作用就是划分碰撞域,解决碰撞问题,降低碰撞几率
7. 跨网络通信
跨网络通信就是分别处于不同局域网的两台主机之间进行通信,要知道两个局域网之间进行通信是要经过路由器的,所以这两台主机进行通信要经过至少一台路由器,更多的时候是多台路由器,下面是跨网段的两台主机的文件传输,中间会经过很多台路由器,下面的过程只经过一台路由器:
- 可以看到的是,这里的通信比局域网内通信多了一个封装和分用的过程,数据封装完毕不是把数据包直接通过网络交付给对端的数据链路层,而是交付给路由器,这是为什么呢?
路由器横跨两个局域网,两个局域网负责设备之间的数据帧传送的网络协议可能是不同的,有以太网、令牌环网和无限LAN等通信协议标准。
在上面的图片中显示,一个局域网使用的是以太网协议标准,两一个局域网使用的是令牌环网,双方的标准有差异不能够直接通信,所以就需要有中间媒介处理进行处理,这个媒介就是路由器。前面说过了,路由器是从网络层到物理层,所以数据封装完毕会把数据包交付给路由器,最后一次封装会添加路由器的mac帧(这样局域网内的主机就可以找到对应的那一台路由器,局域网的主机都认为路由器是自己所在局域网的一台主机)。
路由器处于数据链路层的以太网驱动会把数据包中的以太网协议(路由器mac帧)报头去掉,将剩下的有效载荷交付给路由器的网络层,路由器根据目的IP地址,查询路由表进行路由转发;然后将数据包进行向下交付给令牌环网的驱动程序,它会给数据添加上对端的令牌环网协议报头信息,最后将数据包传送给对端协议栈的数据链路层
8. 网络中的地址管理
认识IP地址
IP协议有两个版本, IPv4和IPv6. 我们整个的课程, 凡是提到IP协议, 没有特殊说明的, 默认都是指IPv4
- IP地址是在IP协议中, 用来标识网络中不同主机的地址;
- 对于IPv4来说, IP地址是一个4字节, 32位的整数;
- 我们通常也使用 “点分十进制” 的字符串表示IP地址, 例如 192.168.0.1 ; 用点分割的每一个数字表示一个字节, 范围是 0 - 255;
- IP地址可以由人手动静态分配,也可以让路由器来动态分配
认识MAC地址
- MAC地址用来识别数据链路层中相连的节点;
- 长度为48位, 及6个字节. 一般用16进制数字加上冒号的形式来表示(例如: 08:00:27:03:fb:19)
- 在网卡出厂时就确定了, 不能修改. mac地址通常是唯一的(虚拟机中的mac地址不是真实的mac地址, 可能会冲突; 也有些网卡支持用户配置mac地址)
通过指令查看IP和MAC
通过ifconfig 可以查看主机的网络信息
二、网络的构成
搭建一套网络环境需要涉及到很多电缆和网络设备,下面只介绍下连接计算机和计算机的硬件设备以及一些关键概念:
1. 物理层:
(1)数据链路
在数据通信网中,按一种链路协议的技术要求连接两个或多个数据站的电信设施,称为数据链路,简称数据链。数据链路(data link)
除了物理线路
外,还必须有通信协议
来控制这些数据的传输。若把实现这些协议的硬件和软件加到链路上,就构成了数据链路
。
数据链路包括传输的物理媒体、链路协议、有关设备以及有关计算机程序。但不包括提供数据的功能设备(即数据源)和接收数据的功能设备。
传输速率
:数据传输过程中,两个设备之间数据流动的物理速度称为传输速率,单位为bps(Bits Per Second,每秒比特数),即单位时间内传输的数据量多少。传输速率又称为带宽
,带宽越大网络传输能力就越强吞吐量
:主机之间实际的传输速率称为吞吐量,单位为bps。吞吐量不仅衡量带宽,同时还有主机的CPU处理能力、网络拥堵程度、报文中数据字段的占有份额(不含报文首部,只计算数据字段本身)等信息
(2)中继器
很久以前的计算机是不与任何其它计算机相连的,直到某天俩个计算机为了合力完成某些事情而建立通信,于是俩台计算机各开了一个网口,使用通信媒介连接起来,为了解决信号在传输过程值的衰减问题,在通信媒介中间使用中继器来连接,实现信号的还原。
OSI模型中第一层,即物理层面上延长网络的设备
;由电缆传过来的波信号或光信号,经由中继器波形调整和放大再传给另一个电缆。
一般情况下,中继器两端连接的是相同的通信媒介(有些中继器也可完成不同通信媒介之间的转接工作)
有些中继器
可提供多个端口服务,被称为中继集线器(Hub)
或者集线器
,每个端口都可称为一个中继器。
(3)集线器
实现俩台计算机相连后,不断地有更多计算机想要连接在一起,但是一台计算机想要和其它每一台计算机相连就需要和其它计算机等同数量的网口,而计算机实际上是不可能开如此之多的网口的。如下图(使用红线连接的计算机是不能实现直接相连的,因为网口不够多)
于是你们发明了一个中间设备
,你们将网线都插到这个设备上,由这个设备做转发
,就可以彼此之间通信了,本质上和原来一样,但是网口的数量和网线的数量减少了,不再那么混乱
这个中间设备就是集线器(HUB)
,它是工作在物理层设备
, 它的工作机制流程是:从一个端口接收到数据包时,会在其他端口把这个包转发一次,因为它不知道也不可能知道这个包是发给谁的(物理层设备只关心电压这些物理概念),它也只能对所有人广播,让他们自己处理。
(4)网卡
由于转发到了所有出口,那 BCDE 四台机器怎么知道数据包是不是发给自己的呢?
首先,你要给所有的连接到交换机的设备都起个名字。原来这些计算机被称为 ABCD,但现在需要一个更专业的,全局唯一的名字作为标识,这个名字称为 MAC 地址
任何计算机连接网络时,必须使用网卡(全称网络接口卡,也称为网络适配器、网卡、LAN卡) 网卡是一块被设计用来允许计算机在计算机网络上进行通讯的计算机硬件。由于其拥有MAC地址(
Media Access Control Address
),MAC地址在世界上是唯一的
MAC地址的其中前 24 位代表网络硬件制造商的编号,后 24 位是该厂家自己分配的,一般表示系列号
假设A主机的 MAC 地址是 aa-aa-aa-aa-aa-aa,而B主机 的 MAC 地址是 bb-bb-bb-bb-bb-bb,以此类推,不重复。这样,A 在发送数据包给 B 时,只要在头部拼接一个这样结构的数据,就可以了。
每个主机
都可以收到这一份数据
,且用自己的MAC地址与数据中的目标主机MAC地址进行比较
,如果不同
,表明该数据不是发给自己的,就将数据丢掉
,相同
就表明该数据是发给自己的,就收下
。这就是我们之前所说的局域网通信原理
虽然集线器使整个局域网干净了不少,但是系那种又有了另一个严重对问题:如果一个大型的局域网,比如有500台机器,全部用HUB连接的,那么后果就是网络的效率极差!为什么?
如果500台机器都发一个包,那就是说每台机器,都需要接收差不多499个无用包,并且如果是需要回应的话,无用的数据包会充斥着整个的局域网,对网络资源造成了极大的浪费,这就是广播风暴
2. 数据链路层
(1)网桥
1. 认识网桥
为了减少广播风暴,网桥产生了;网桥
又称桥接器,英文名Network Bridge,数据链路层设备
。它也是转发数据包的设备,但和HUB不一样的是,它工作在数据链路层,HUB只能看懂物理层上的东西(比如一段物理信号),网桥却能看懂一些帧的信息(在链路层上,把上面传下来的数据封装后,封装好了的数据就是帧,但这里我用“数据包”这样的泛指去代替“帧”这个专业术语)。
在以太网构造的局域网上,最终的寻址是以数据链路层的MAC地址作为标识的(就是用MAC地址可以在局域网上找到一台唯一的机器),网桥能从发来的数据包中提取MAC信息,并且根据MAC信息对数据包进行有目的的转发,而不采用广播的方式,这样就能减少广播风暴的出现,提升整个网络的效率
网桥可以如下图所示,将同一个局域网分开,这样主机A发送的数据包就不需要发给该局域网内所有主机了,而是通过网桥分割冲突域。主机A先在自己所在的冲突域进行广播,网桥收到数据包后再判断是否将数据包发送到另一个冲突域,发送到另一个冲突域后由另一个冲突域的集线器进行广播
2. 网桥的工作原理
上图是用一个网桥连接的两个网络,网桥的A端口连接A子网,B端口连接B子网。为什么网桥知道哪些数据包该转发,哪些包不该转发呢?
那是因为它有两个表A和B,当有数据包进入端口A
时,网桥从数据包中提取出源MAC地址
和目的MAC地址
。一开始的时候,表A和表B都是空的,没有一条记录,这时,网桥会把从端口A
来的数据包广播到端口B(所有端口),并且在表A
中增加一条改数据包
的源MAC地址
,说明这个MAC地址的主机是局域网A的。同理,当局域网B中有主机发送数据包到B端口时,网桥也会记录数据包的源MAC地址到B表。
当网桥工作一段时候后,表A基本上记录了A子网所有的机器的MAC地址,表B同理。当再有一个数据包从A子网发送给网桥时,网桥会先看看数据包的目的MAC地址是属于A子网还是B子网的,如果从A表中找到对应则抛弃该包;如果不是,则转发给B子网。
3. 需要注意的一些问题
网桥用于连接不同的网段?
首先这里要理解什么是网段,它涉及到子网掩码的等等一系列的东西。这里我觉得要明确的是,网桥不是用来连接不同网段的!!!
不同网段之间通信,需要网关的帮助,它一般是路由器这类网络层的设备。网桥或交换机是链路层设备,网段这个是和IP相关的概念,属于网络层。
(2)交换机
1. 认识交换机
一开始的时候,由于硬件水平不是很发达,人们为了提高局域网效率,减少广播风暴的出现,他们生产了网桥,然后他们把一个局域网一分为2,中间用网桥连接,这样A发给BCD的数据就不会再广播到EFGH了,只有从A发到EFGH的数据包才能通过网桥,到达另外一个子局域网。
这样一来,非必要的传输减少了,整个网络的效率也随之提高可不少!随着硬件发展,出现了4个、8个端口的网桥,这就是交换机;我们也可以将交换机看作“智能的”集线器,它不会像集线器一样将收到的数据包广播,而只将数据包发送给指定的主机。
交换机
是链路层设备
。由于交换机可以使得网络更安全,网络效率更高,交换机渐渐替代了HUB,成为组建局域网的重要设备。
但是随着相互连接的计算器数量的继续增多,这种一对一的交换机的接口就不够了,而此时实际上只需要将多个交换机连接起来交换机接口不足的问题就被解决了
但是需要注意,上面那根红色的线,最终在 MAC 地址表中可不是一条记录,而是要把 EFGH 这四台机器与该端口(端口6)的映射全部记录在表中。
两个交换机将分别记录 A ~ H 所有机器的映射记录如下:
网桥和交换机,基本上是一样的,但细看还是会有些不一样。
2. 交换机的工作原理
在计算机网络系统中,交换机是针对共享工作模式的弱点而推出的。交换机拥有一条高带宽的背部总线和内部交换矩阵。交换机的所有的端口都挂接在这条背部总线上,当控制电路收到数据包以后,处理端口会查找内存中的地址对照表以确定目的MAC(网卡的硬件地址)的NIC(网卡)挂接在哪个端口上,通过内部交换矩阵迅速将数据包传送到目的端口。目的MAC若不存在,交换机才广播到所有的端口,接收端口回应后交换机会“学习”新的地址,并把它添加入内部地址表中。在今后的通讯中,发往该MAC地址的数据包将仅送往其对应的端口,而不是所有的端口。
因此,交换机可用于划分数据链路层广播,即冲突域;但它不能划分网络层广播,即广播域。
交换机也有一张MAC-PORT对应表
(这张表称为:CAM
)。和网桥不一样的是,网桥的表是一对多的(一个端口号对多个MAC地址),但交换机的CAM表却是一对一的。因此如果一个端口有新的MAC地址,它不会新增MAC-PORT记录,而是修改原有的记录。
比如:现在交换机记录表里已经有一项:MAC1-Port1,如果此刻端口1又来了一个数据包,里面的源MAC地址是MAC2,此时,交换机会刷新交换机记录表:MAC1-Port1记录被修改为MAC2-Port1,因为交换机认为是端口1的计算机MAC地址变了,如果端口1连接的一台物理机器,MAC一般是不会变的,如果连接的是另外一个交换机,那这个端口的记录会变化得比较频繁(如上图的Port12,它是对外的接口,与一个局域网连接)。
交换机被广泛应用于二层网络交换,俗称“二层交换机”。
交换机的种类有:二层交换机、三层交换机、四层交换机、七层交换机分别工作在OSI七层模型中的第二层、第三层、第四层盒第七层,并因此而得名。
(3)冲突域和广播域
网桥和交换机
用户分割冲突域
,就是网桥和交换机可以较少被逼的广播(hub导致的),但不能分割广播域
。不严格地说,交换机可以看作网桥的高度集成。
①冲突域:总的来说,冲突域就是连接在同一导线上的所有工作站的集合,或者说是同一物理网段上所有节点的集合,或以太网上竞争同一带宽的节点集合。HUB这种设备不能分割冲突域。
②广播域:网络中能接收任一设备发出的广播帧的所有设备的集合。
- HUB 所有端口都在同一个广播域,冲突域内。
- Switch所有端口都在同一个广播域内,而每一个端口就是一个冲突域。
- Router的每个端口属于不同的广播域。
3. 网络层
(1)路由器
上面那种使用交换机嵌套的组网方式甚至在只有几百台电脑的时候,都可以支持。但是计算机的数量很快就发展到几万,甚至几十万。
其实问题的根本在于,连出去的那根红色的网线,后面不知道有多少个设备不断地连接进来,从而使得地址表越来越大。那可不可以让那根红色的网线,接入一个新的设备,这个设备就跟电脑一样有自己独立的 MAC 地址,而且同时还能帮我把数据包做一次转发呢?
这个设备就是路由器
,它的功能就是,作为一台独立的拥有 MAC 地址
的设备,并且可以帮我把数据包
做一次转发
,它工作在网络层
。
注意,路由器的每一个端口,都有独立的 MAC 地址,那么现在交换机的 MAC 地址表中,只需要多出一条 MAC 地址 ABAB 与其端口的映射关系,就可以成功把数据包转交给路由器了。那如何做到,把主机A发送给 C 和 D的数据包,统统先发送给路由器呢?路由器又是如何将数据包转发到C和D呢?
答案是主机A通过对比源IP地址
和目的IP地址
判断源主机和目标主机是否在同一个子网
下,如果在同一个子网下则可以直接使用交换机实现发送数据包,若不再同一个子网下,那么主机A就将数据包发送给默认网关
,路由器也就可以拿到该数据包了,然后路由器根据路由表
进行数据包转发。
这句话有许多新的名词,我们接下来来认识这些名词,认识了这些名词,也就知道了该问题是如何解决的
我们先继续了解路由器:
路由器(Router)是一种计算机网络设备,提供路由
与转送
两种重要机制,可以决定数据包从来源端到目的端所经过的路由路径(host到host之间的传输路径),这个过程称为路由;将路由器输入端的数据包移送至适当的路由器输出端(在路由器内部进行),这称为转送。
注意:路由器即可以用来区分不同的子网,也可以用来区分不同的网段
我们所认识的路由器设备中有俩种接口,一种是LAN口
,另一种是WAN口
。LAN口可以有多个可以用来连接家庭网络设备,必须台式机,手机和笔记本(其中手机和笔记本通过wifi连接路由器的LAN口)。而WAN口只有一个,用来接入运营商网络,以连接到互联网中。
如果将路由器WAN口忽略,只使用LAN口,那么路由器可以看作一台交换机(上图中我们是将交换机和路由器分开来看待的)
俩台同一个子网下的主机如何通信呢? 答案是通过路由器作为“交换机”的功能,将数据发送到路由器,然后路由器将数据发送给另一台主机进行通信的,是不会经过WAN口的。
那么什么时候会经过WAN口呢? 答案就是该子网访问外网时需要经过WAN口,此时这个WAN口就起着“网关”
的作用。
路由器的一个作用是连通不同的网络,另一个作用是选择信息传送的线路。
(2)IP地址
为了将数据包发送给目标主机,那么源主机就需要判断目标主机和原主机是否在同一个子网下,如果目标主机和原主机在同一个子网下,则源主机通过交换机直接将数据发送到目的主机;如果目的主机和源主机不再同一个子网下,则源主机先将目的MAC地址改为路由器的MAC地址,然后通过交换机发送给路由器。
所以判断目的主机和源主机是否在同一个子网下是非常关键的。由于同一个子网下的MAC地址不一定会有相同的前缀,于是就发明了在同一个子网下有相同前缀的IP地址。
IP地址是一个32位的地址,每一台电脑,同时有自己的 MAC 地址,又有自己的 IP 地址,只不过 IP 地址是软件层面上的,可以随时修改,MAC 地址一般是无法修改的。
(3)子网
如果源 IP 与目的 IP 处于一个子网,直接将包通过交换机发出去。
如果源 IP 与目的 IP 不处于一个子网,就交给路由器去处理。
假如某台机器的子网掩码定为 255.255.255.0。这表示,将源 IP 与目的 IP 分别同这个子网掩码进行与运算,相等则是在一个子网,不相等就是在不同子网,就这么简单。
A电脑:192.168.0.1 & 255.255.255.0 = 192.168.0.0
B电脑:192.168.0.2 & 255.255.255.0 = 192.168.0.0
C电脑:192.168.1.1 & 255.255.255.0 = 192.168.1.0
D电脑:192.168.1.2 & 255.255.255.0 = 192.168.1.0
那么 A 与 B 在同一个子网,C 与 D 在同一个子网,但是 A 与 C 就不在同一个子网,与 D 也不在同一个子网,以此类推。
所以如果 A 给 C 发消息,A 和 C 的 IP 地址分别 & A 机器配置的子网掩码,发现不相等,则 A 认为 C 和自己不在同一个子网,于是把包发给路由器,就不管了,之后怎么转发,A 不关心。
(4)网段
网段
是由一组IP地址构成的IP地址空间
,而子网则是将一个IP地址空间划分成更小的片段区域。网段是TCP/IP网络的基本单元,而子网则是管理和组织网络流量、加强网络安全等方面非常重要的工具。它们之间的关系是:
子网是在网段的基础上划分的更小的一个地址段,一般会把同一个网段内的主机划分成更小的子网。子网掩码用来区分网段和子网。子网掩码的位数决定了该网络中子网数量。它可以将一个网络划分为不同的子网,以管理和控制流量和安全。它允许在一个IP地址空间中定义多个子网,每个子网可以有自己的网络地址,这就为网络管理员提供了很大的灵活性和管理能力,同时也提高了网络的安全性。
例如,192.168.0.0
网络可以被划分为多个子网,如192.168.0.0/24
、192.168.0.0/25
、192.168.0.128/25
等。其中,192.168.0.0/24
是指使用了24位子网掩码的子网,即前三个字节为网络部分,最后一个字节为子网部分。这个子网可以容纳256个主机(因为最后一个字节留给主机地址),其网络地址为192.168.0.0
,广播地址为192.168.0.255
。而192.168.0.0/25
表示前25位为网络部分,剩下的为主机部分,这个子网可以容纳128个主机,其网络地址为192.168.0.0
,广播地址为192.168.0.127
。
总之,子网是在网段的基础上划分的更小的一个地址段,子网掩码用来区分网段和子网。子网的划分可以提高网络的可管理性和安全性,是构建TCP/IP网络中必不可少的一个要素。
(5)网关
网关(Gateway),网关顾名思义就是连接两个网络的设备。由于历史的原因,许多有关TCP/IP的文献曾经把网络层使用的路由器(Router)称为网关,在今天很多局域网采用都是路由来接入网络,因此现在通常指的网关就是路由器的IP;网关也经常指把一种协议转成另一种协议的设备。它是一种网络中的中介设备,能够"转发"和"处理跨不同网络或协议"的数据
网关实质上是一个网络通向其他网络的IP地址。 俩个不同处于子网的主机要想进行通信,就需要通过路由器的WAN口,也就是需要通过网关进行转发。计算机的网关(Gateway)就是到其他网段的出口,也就是路由器接口IP地址。路由器接口使用的IP地址可以是本网段中任何一个地址,不过通常使用该网段的第一个可用的地址或最后一个可用的地址,这是为了尽可能避免和本网段中的主机地址冲突。
网关可以作为网络中不同子网或不同协议之间的桥梁,通过将不同网络或协议的数据转换成适当的格式,让这些网络或协议相互通信。网关可以将 Internet 上的数据与局域网上的数据相互连接,也可以将 IPv4 数据转换为 IPv6 数据,或者将 HTTP/HTTPS 请求转换为 FTP/SFTP 请求等。
(6)默认网关
A 通过是否与 C 在同一个子网内,判断出自己应该把包发给路由器,那主机A怎么知道路由器的 IP 是多少呢?
其实说发给路由器不准确,应该说 A 会把包发给默认网关。对 A 来说,A 只能直接把包发给同处于一个子网下的某个 IP 上,所以发给路由器还是发给某个电脑,对 A 来说也不关心,只要这个设备有个 IP 地址就行。
所以默认网关,就是 A 在自己电脑里配置的一个 IP 地址,以便在发给不同子网的机器时,发给这个 IP 地址。
(6)路由表
现在 A 要给 C 发数据包,已经可以成功发到路由器这里了,最后一个问题就是,路由器怎么知道,收到的这个数据包,该从自己的哪个端口出去,才能直接(或间接)地最终到达目的地 C 呢。
路由器收到的数据包有目的 IP 也就是 C 的 IP 地址,需要转化成从自己的哪个端口出去,很容易想到,应该有个表,就像 MAC 地址表一样,这个表就叫路由表。
不同于 MAC 地址表的是,路由表并不是一对一这种明确关系,我们下面看一个路由表的结构
路由表就表示,192.168.0.xxx 这个子网下的,都转发到 0 号端口,192.168.1.xxx 这个子网下的,都转发到 1 号端口。下一跳列还没有值,我们先不管
(7)ARP协议
刚才说的都是 IP 层,但发送数据包的数据链路层需要知道 MAC 地址,可是我们只知道 IP 地址该怎么办呢?
假如主机A此时不知道主机B的 MAC 地址,你只知道它的 IP 地址,你该怎么把数据包准确传给 B 呢?答案很简单,在网络层,我需要把 IP 地址对应的 MAC 地址找到,可以通过ARP协议,找到 192.168.0.2 对应的 MAC 地址 BBBB。
这种方式就是arp 协议
,同时电脑 A 和 B 里面也会有一张 arp 缓存表,表中记录着 IP 与 MAC 地址的对应关系。
IP地址 | MAC地址 |
---|---|
198.168.0.2 | BBBB |
一开始的时候这个表是空的,电脑 A 为了知道电脑 B(192.168.0.2)的 MAC 地址,将会广播一条 arp 请求,B 收到请求后,带上自己的 MAC 地址给 A 一个响应。此时 A 便更新了自己的 arp 表。
这样通过所有主机都可以通过不断广播 arp 请求,最终所有主机里面都将 arp 缓存表更新完整。
(8)数据包在网络层的传输过程
现在两个设备之间传输,除了加上数据链路层的头部之外,还要再增加一个网络层的头部。
- 假如 A 给 B 发送数据,由于它们直接连着交换机,所以 A 直接发出如下数据包即可,其实网络层没有体现出作用。
- 但假如 A 给 C 发送数据,A 就需要先转交给路由器,然后再由路由器转交给 C。由于最底层的传输仍然需要依赖以太网,所以数据包是分成两段的。
- A到路由器这段的包如下:
- 路由器到C这段的包如下:
4. 整个传输过程
(1)电脑视角:
- 首先我要知道我的 IP 以及对方的 IP
- 通过子网掩码判断我们是否在同一个子网
- 在同一个子网就通过 arp 获取对方 mac 地址直接扔出去
- 不在同一个子网就通过 arp 获取默认网关的 mac 地址直接扔出去
(2)交换机视角:
- 我收到的数据包必须有目标 MAC 地址
- 通过 MAC 地址表查映射关系
- 查到了就按照映射关系从我的指定端口发出去
- 查不到就所有端口都发出去
(3)路由器视角:
- 我收到的数据包必须有目标 IP 地址
- 通过路由表查映射关系
- 查到了就按照映射关系从我的指定端口发出去(不在任何一个子网范围,走其路由器的默认网关也是查到了)
- 查不到则返回一个路由不可达的数据包
如果你嗅觉足够敏锐,你应该可以感受到下面这句话:网络层(IP协议)本身没有传输包的功能,包的实际传输是委托给数据链路层(以太网中的交换机)来实现的。
(4)涉及到的三张表分别是
- 交换机中有 MAC 地址表用于映射 MAC 地址和它的端口
- 路由器中有路由表用于映射 IP 地址(段)和它的端口
- 电脑和路由器中都有arp 缓存表用于缓存 IP 和 MAC 地址的映射关系
这三张表是怎么来的?
- MAC 地址表是通过以太网内各节点之间不断通过交换机通信,不断完善起来的。
- 路由表是各种路由算法 + 人工配置逐步完善起来的。
- arp 缓存表是不断通过 arp 协议的请求逐步完善起来的。
知道了以上这些,目前网络上两个节点是如何发送数据包的这个过程,就完全可以解释通了!
4. 4~7层交换机
4~7层交换机负责处理OSI模型中从传输层至应用层的数据;即以TCP等协议的传输层及其上面的应用层为基础,分析收发数据,并对其进行特定的处理(例如:负载均衡器)
应用场景:带宽控制、广域网加速器、特殊应用访问、防火墙等
(1)负载均衡器
负载均衡器(Load Balancer)
是一种网络设备,其主要作用是将大量用户请求转发到多台服务器或计算机上,以达到降低每台服务器累计的负载,从而提高系统的处理能力和性能的目的。负载均衡器能够监控服务器状态,并根据不同的负载均衡算法将请求分发到不同的服务器上,从而有效避免服务器过载或单点故障的问题,提高系统的可用性和可靠性。
(2)防火墙
防火墙
通常被部署在TCP/IP协议栈
的第三层(网络层)或第四层(传输层),但具体位置取决于防火墙的类型和功能。
如果是基于规则的防火墙,通常工作在第三层,通过检查网络层IP数据包的信息,如源IP地址、目标IP地址、端口号等来确定是否允许或拒绝数据包通过。这种防火墙通常不会处理数据包的内容,只能做一些简单的端口过滤和IP地址过滤等。
而基于内容的防火墙,则工作在第四层,它可以读取和解码传输层的TCP和UDP数据包,并对数据包的内容进行深度检查。当数据包中包含特定的协议标识符、攻击特征或其他恶意代码时,防火墙就会拦截并阻止这些数据包的传输。
此外,最近一些新型的防火墙,如应用层防火墙,工作在更高的网络层次上,它们可以在第七层查看特定应用程序的数据包,检测来自Web应用程序和电子邮件等协议的攻击。
5. 代理服务器
代理服务器(Proxy Server)
是一种网络设备或应用程序,其可以代理用户和客户端设备与服务器进行通信。代理服务器能够转发
客户端请求和服务器的响应数据,并且可以修改
客户端的请求或服务器的响应,从而提高网络性能、安全性或访问控制
代理服务器的一些常见用途包括:
网络代理
:代理服务器可以充当客户端与服务器中间的翻译,它可以帮助客户端处理请求和响应数据,也可以缓存一些请求和响应数据,从而提高用户的访问速度和网络性能。节省带宽
:代理服务器可以缓存一些静态数据,避免客户端每次都重新请求,从而节省带宽和网络资源。过滤和访问控制
:代理服务器能够根据一些过滤规则或访问控制策略,拦截或允许客户端请求和服务器的响应,从而实现一些安全策略,例如防火墙、反病毒等。匿名浏览
:代理服务器可以隐藏客户端的真实 IP 地址,保护用户隐私,也可以用于访问受限制的网站或绕过地理限制。
需要注意的是,代理服务器并不是万能的网络解决方案,在某些情况下,代理服务器可能会对网络性能和安全产生一些影响。因此,在使用代理服务器时,应该根据实际情况进行合理的选择和配置。
6. web(全球广域网)
web(World Wide Web)即全球广域网,也称为万维网,它是一种基于超文本和HTTP的、全球性的、动态交互的、跨平台的分布式图形信息系统。是建立在Internet上的一种网络服务,为浏览者在Internet上查找和浏览信息提供了图形化的、易于访问的直观界面,其中的文档及超级链接将Internet上的信息节点组织成一个互为关联的网状结构。web只是Internet上的一个应用层服务。
三、网络套接字编程
1. 预备知识
(1)源IP地址和目的IP地址
IP地址是用来标识网络中不同主机的地址。两台主机进行通信时,发送方需要知道自己往哪一台主机发送,这就需要知道接受方主机的的IP地址,也就是目的IP地址,因为两台主机是要进行通信的,所以接收方需要给发送方进行一个响应,这时接收方主机就需要知道发送方主机的IP地址,也就是源IP地址。这个IP最大的意义就是指导路由器进行路由转发。有了这两个地址,两台主机才能够找到对端主机。
- 源IP地址: 发送方主机的IP地址,保证响应主机“往哪放”
- 目的IP地址: 接收方主机的IP地址,保证发送方主机“往哪发”
(2)端口号
端口号是属于传输层协议的一个概念,它是一个16位的整数,用来标识主机上的某一个进程。
注意: 一个端口号只能被一个进程占用
公网IP地址是用来标识全网内唯一的一台主机,端口号又是用来标识一台主机上的唯一一个进程,所以IP地址+端口号 就可以标识全网内唯一一个进程
端口号
和进程ID
都
- 一台主机上可以存在大量的进程,但不是所有的进程都需要对外进行网络请求。任何的网络服务和客户端进程通信,如果要进行正常的数据通信,必须要用端口号来唯一标识自身的进程,只有需要进行网络请求的进程才需要用端口号来表示自身的唯一性,所以说端口号更多的是网络级的概念。进程pid可以用来标识所有进程的唯一性,是操作系统层面的概念。二者是不同层面表示进程唯一性的机制。
源端口号
和目的端口号
:
- 两台主机进行通信,只有对端主机的IP地址只能够帮我们找到对端的主机
(主机到主机到通信)
,但是我们还需要找到对端提供服务的进程(端到端的通信)
,这个进程可以通过对端进程绑定的端口号找到,也就是目的端口号
。同样地,对端主机也需要给发送方一个响应,通过源IP地址找到发送方的那一台主机,找到主机还是不够的,还需要找到对端主机是哪一个进程发起了请求,响应方需要通过发起请求的进程绑定的端口号找到该进程,也就是源端口号
,然后就可以进行响应
。
- 源端口号: 发送方主机的服务进程绑定的端口号,保证接收方能够找到对应的服务
- 目的端口号: 接收方主机的服务进程绑定的端口号,保证发送方能够找到对应的服务
socket通信的本质: 跨网络的进程间通信。从上面可以看出,网络通信就是两台主机上的进程在进行通信。
(3)认识TCP协议和UDP协议
这两个协议都是传输层的协议,这里不会过多地介绍两个协议的具体细节,因为现在我们只有认识它们就够了,更多的细节会在后面的内容中具体介绍。
TCP(Transmission Control Protocol)协议: 传输控制协议
- 传输层协议
- 有连接
- 可靠传输
- 面向字节流
UDP(User Datagram Protocol)协议: 用户数据报协议
- 传输层协议
- 无连接
- 不可靠传输
- 面向数据报
为什么还需要不可靠传输协议?
答:可靠意味着需要进行更多的工作来保证可靠性,成本也会更多,效率也会低一些。不可靠协议的特点就是简单,高效。实际中,我们需要根据需求来选择不同的协议。
(4)网络字节序
内存中的多字节数据的存储相对于内存地址有大端和小端之分:
- 大端字节序: 高位存放在低地址,低位存放在高地址
- 小端字节序: 低位存放在低地址,高位存放在高地址
网络数据流同样有大端和小端之分,发送主机通常将发送缓冲区中的数据按内存地址从低到高的顺序发出,接收主机把从网络上接到的字节依次保存在接收缓冲区中,也是按内存地址从低到高的顺序保存,所以网络数据流的地址规定如下:
- 先发出的数据是低地址,后发出的数据是高地址
但是如果双方主机的数据在内存存储的字节序不同,就会造成接收方收到的数据出现偏差,所以为了解决这个问题,又有了下面的规定:
- TCP/IP协议规定,网络数据流采用
大端字节序
,不管这台主机是大端机还是小端机, 都会按照这个TCP/IP规定的网络字节序来发送/接收数据
所以如果发送的主机是小端机,就需要把要发送的数据先转为大端,再进行发送,如果是大端,就可以直接进行发送。
为了方便我们进行网络程序的代码编写,有下面几个API提供给我们用来做网络字节序和主机字节序的转换,如下:
#include <arpa/inet.h>
uint32_t htonl(uint32_t hostlong);
uint16_t htons(uint16_t hostshort);
uint32_t ntohl(uint32_t netlong);
uint16_t ntohs(uint16_t netshort);
说明:
- h代表的是host,n代表的是network,s代表的是16位的短整型,l代表的是32位长整形
- 如果主机是小端字节序,函数会对参数进行处理,进行大小端转换
- 如果主机是大端字节序,函数不会对这些参数处理,直接返回
2. socket常见接口
// 创建 socket 文件描述符 (TCP/UDP, 客户端 + 服务器)
int socket(int domain, int type, int protocol);
// 绑定端口号 (TCP/UDP, 服务器)
int bind(int socket, const struct sockaddr *address,
socklen_t address_len);
// 开始监听socket (TCP, 服务器)
int listen(int socket, int backlog);
// 接收请求 (TCP, 服务器)
int accept(int socket, struct sockaddr* address,
socklen_t* address_len);
// 建立连接 (TCP, 客户端)
int connect(int sockfd, const struct sockaddr *addr,
socklen_t addrlen);
(1)sockaddr结构
sockaddr是一个结构体,里面保存的是通信需要用到的IP,端口号,协议族等信息。需要我们手动填入对应内容,并传入通信api,或者作为输出型参数拿到对端的信息。
- sockaddr_in用来进行网络通信,sockaddr_un结构体用来进行本地通信
- sockaddr_in结构体存储了协议家族,端口号,IP等信息,网络通信时可以通过这个结构体把自己的信息发送给对方,也可以通过这个结构体获取远端的这些信息
- 这三个结构体的
前16位
是一样的,代表的是协议家族
,可以根据这个参数判断需要进行哪种通信(本地和跨网络) - IPv4和IPv6的地址格式定义在netinet/in.h中,IPv4地址用sockaddr_in结构体表示,包括16位地址类型, 16位端口号和32位IP地址.
- IPv4,IPv6地址类型分别定义为常数AF_INET、 AF_INET6. 这样,只要取得某种sockaddr结构体的首地址,不需要知道具体是哪种类型的sockaddr结构体,就可以根据地址类型字段确定结构体中的内容
socket API
可以都用struct sockaddr *
类型表示,在使用的时候需要强制转化成sockaddr
;这样的好处是程序的通用性,不需要为不同的通信种类设计不同的API。可以接收IPv4,IPv6, 以及UNIX Domain Socket各种类型的sockaddr结构体指针为参数
我们平常使用的是sockaddr_in,而api接口参数在设计的时候通常都是sockaddr,为的是接口的通用性,在传入不同结构体时用同一个接口,平常传参要把sockaddr_in的数据强转成sockaddr
<1> sockaddr结构
打开 /usr/include/sys/socket.h
struct sockaddr{
sa_familt_t sa_family;
char sa_data[14]; //14字节,包含套接字中的目标地址和端口信息
}
<2> sockaddr_in结构
打开 /usr/include/linux/in.h
sin_family
代表的是地址类型,我们主要用的是AF_INET
,sin_port
代表的是16位的端口号
,sin_addr
代表的是网络地址
,也就是IP地址
,用了一个结构体struct in_addr
进行描述,该结构体如下:
这里填充的就是IP地址,是一个32位的无符号整数
<3> sockaddr和sockaddr_in总结
二者长度一样,都是16个字节,即占用的内存大小是一致的,因此可以互相转化。二者是并列结构,指向sockaddr_in结构的指针也可以指向sockaddr。
sockaddr常用于bind、connect、recvfrom、sendto等函数的参数,指明地址信息,是一种通用的套接字地址。
sockaddr_in 是internet环境下套接字的地址形式。所以在网络编程中我们会对sockaddr_in结构体进行操作,使用sockaddr_in来建立所需的信息,最后使用类型转化就可以了。一般先把sockaddr_in变量赋值后,强制类型转换后传入用sockaddr做参数的函数:sockaddr_in用于socket定义和赋值;sockaddr用于函数参数。
(2)地址转换函数
#include <arpa/inet.h>
int inet_aton(const char *cp, struct in_addr *inp);
in_addr_t inet_addr(const char *cp);
int inet_pton(int af, const char *src, void *dst);
在sockaddr_in结构体中的in_addr中的s_addr保存了主机的IP地址,它是一个32位的无符号整数,而我们所看到的IP地址一般是点分十进制类型的字符串风格,所以在进行设置sockaddr.in_addr.s_addr时,需要将人所识别的点分十进制转换为32位整数。并且我们知道向网络中发送的数据需要按照网络字节序来发出,不可以使用主机序列,所以在将IP地址转换为32位整数后还是需要将起从主机序列转换为网络序列。而inet_addr
就可以完成这俩个任务。
inet_addr函数
- 函数原型
in_addr_t inet_addr(const char *cp);
- 作用:将点分十进制的主机地址转换为网络字节序的32位无符号整数IP地址。
- 返回值:正常返回32位整数IP地址。参数无效和出错均返回-1。
- 注意:这个函数有个缺点,在处理地址为255.255.255.255时也返回-1,虽然它是一个有效地址,但inet_addr()无法处理这个地址
inet_aton函数
- 函数原型
int inet_aton(const char *cp, struct in_addr *inp);
- 作用:将点分十进制的网络主机地址ip(如192.168.1.10)为二进制数值,并存储在struct in_addr结构中,即第二个参数*inp。
- 返回值:函数返回非0表示cp主机有地有效,返回0表示主机地址无效。
- 注意:这个转换完后不能用于网络传输,还需要调用htons或htonl函数才能将主机字节顺序转化为网络字节顺序
inet_ntoa函数
- 函数原型
char *inet_ntoa(struct in_addr in);
- 作用:inet_ntoa 函数转换网络字节排序的地址为标准的ASCII以点分开的地址,该函数返回指向点分开的字符串地址(如192.168.1.10)的指针。
- 注意: inet_ntoa这个函数内部会申请一块空间,保存转换后的IP的结果,这块空间被放在静态存储区,不需要我们手动释放。且第二次调用该函数,会把结果放到上一次的静态存储区中,所以会覆盖上一次调用该函数的结果,是线程不安全的。inet_ntopa这个函数是由调用者自己提供一个缓冲区保存结果,是线程安全的。
(3)socket接口
- 函数原型:
int socket(int domain, int type, int protocol);
- 功能:创建套接字(可以理解为创建一个网络文件),通常这是第一个调用的socket接口
- 参数
-
- domain:指明使用的协议族。常用的协议族有,AF_INET、AF_INET6、AF_LOCAL(或称AF_UNOX,Unix域socket)、AF_ROUTE等等。协议族决定了socket的地址类型,在通信中必须采用对应的地址,如
AF_INET
决定了要用ipv4地址
(32位的)与端口号(16位的)的组合,AF_INET
决定了使用ipv6地址
类型,AF_UNIX决定了要用一个绝对路径名作为地址。我们用的都是IPV4,这里会填AF_INET。
- domain:指明使用的协议族。常用的协议族有,AF_INET、AF_INET6、AF_LOCAL(或称AF_UNOX,Unix域socket)、AF_ROUTE等等。协议族决定了socket的地址类型,在通信中必须采用对应的地址,如
-
- type:指明socket类型,有3种
类型 | 说明 |
---|---|
SOCK_STREAM | TCP类型,保证数据顺序及可靠性 |
SOCK_DGRAM | UDP类型,不保证数据接收的顺序,非可靠连接 |
SOCK_RAW | 原始类型,允许对底层协议如IP或ICMP进行直接访问,不太常用 |
-
- protocol:指定协议。最终采用的协议。常见的协议有IPPROTO_TCP、IPPTOTO_UDP。
如果第二个参数选择了SOCK_STREAM,那么采用的协议就只能是IPPROTO_TCP;
如果第二个参数选择的是SOCK_DGRAM,则采用的协议就只能是IPPTOTO_UDP。
当protocol为0时,会自动选择type类型对应的默认协议。
- protocol:指定协议。最终采用的协议。常见的协议有IPPROTO_TCP、IPPTOTO_UDP。
- 返回值:
成功
返回非负的socket描述符
;失败
时返回-1
(4)bind接口
- 函数原型:
int bind(int sockfd, struct sockaddr *my_addr, socklen_t addrlen);
- 功能:将创建的socket绑定到指定的IP地址和端口上,通常是第二个调用的socket接口。
- 参数:
-
- sockfd:想要绑定的“套接字文件”对应的“文件”描述符
-
- my_addr:sockaddr类型结构体地址,用来存储互联网协议族、IP地址和端口等信息(需要自己创建一个sockaddr_in结构体,填充好并进行类型转换传入其地址)
-
- addrlen:第二个参数的大小【通常为sizeof(struct sockaddr)】
- 返回值:
成功
返回0;出错
返回-1
- 注意:
-
- 当socket函数返回一个描述符时,只是存在于其协议族的空间中,并没有分配一个具体的协议地址(这里指IPv4/IPv6地址和端口号的组合),bind函数可以将一组固定的地址绑定到sockfd上。
-
- 通常服务器在启动的时候都会绑定一个众所周知的协议地址,用于提供服务,客户就可以通过它来连接服务器;而客户端可以指定IP或端口也可以不指定,未分配则系统自动分配。这就是为什么通常服务器端在listen之前会调用bind(),而客户端就不会调用,而是在connect()时由系统随机生成一个。
-
- 如果有多个可用的连接(多个IP),内核会根据优先级选择一个IP作为源IP使用。
-
- 如果socket使用bind绑定到特定的IP和port,则无论是TCP还是UDP,都会从指定的IP和port发送数据。
-
- 当调用函数时,不要将端口号置为小于1024的值,因为1~1024是保留端口号,可以使用大于1024中任何一个没有被占用的端口号。
(5)listen接口
- 函数原型
int listen(int sockfd, int backlog)
- 功能:listen()函数仅被
TCP类型的服务器
程序调用,实现监听服务,它实现2件事情: -
- 当socket()创建一个“套接字文件”时,被假设为主动式套接字,也就是说它是一个将调用connect()发起连接请求的客户端套接字;函数listen()将套接字转换为被动式套接字,指示内核接受向此套接字的连接请求,调用此系统调用后TCP状态机由close转换为listen
-
- 第二个参数指定了内核为此套接字排队的最大连接个数
- 参数:
-
- sockfd:需要进行监听的“套接字文件”对应的“文件”描述符;
-
- backlog:指定内核为此套接字维护的最大连接个数,包括”未完成连接队列,即未完成3次握手“、”已完成连接队列,即已完成3次握手,建立连接“。大多数系统缺省值为20。
- 返回值:成功时返回0,错误时返回-1。
(6)accept接口
- 函数原型
int accept(int sockfd, struct sockaddr* addr, socklen_t* addrlen);
- 功能:accept()函数仅被
TCP类型的服务器
程序调用,从已完成连接队列返回下一个建立成功的连接,如果已完成连接队列为空,线程进入阻塞态睡眠状态。 - 参数:
-
- sockfd:从该“套接字文件”中拿去已经建立成功的连接
-
- addr:
输出型参数
,输出一个sockaddr的变量地址,该变量用来存放发起连接请求
的客户端
的协议地址等信息
- addr:
-
- addrlen:输出型参数,说明第二个参数addr的大小长度
- 返回值:
成功时
返回一个建立连接成功的新套接字
对应的描述符
;错误
时返回-1
。 - 注意:
-
- 三次握手完成后, 服务器调用accept()接受连接
-
- 如果服务器调用accept()时还没有客户端的连接请求,就阻塞等待直到有客户端连接上来
-
- 如果给addr 参数传NULL,表示不关心客户端的地址
问题:已经通过socket创建好了一个套接字,accept又返回了一个套接字,这两个套接字有什么区别吗?UDP只又一个套接字就可以进行通信了,而TCP还需要这么多个,这是为什么?
答:
socket创建
的套接字是用来服务端本身进行绑定的。因为UDP是面向数据报,无连接的
,所以创建好一个套接字之后直接等待数据到来即可
。而TCP是面向连接
,需要等待连接的到来,并获取连接,普通的一个套接字是不能够进行连接的监听,这时就需要用的listen来对创建好的套接字进行设置,将其设置为监听状态,这样这个套接字就可以不断监听连接状态,如果连接到来了,就需要通过accept获取连接,获取连接后返回一个值,也是套接字,这个套接字是用来描述每一个建立好的连接,方便维护连接和给对端进行响应,后期都是通过该套接字对客户端进行通信,也就是对客户端进行服务。所以说,开始创建的套接字是与自身强相关的,用来描述自身,并且需要进行监听,所以我们也会称这个套接字叫做监听套接字,获取到的每一个连接都用一个套接字对其进行唯一性标识,方便维护与服务。
(7)connect接口
- 函数原型
int connect(int sockfd, struct sockaddr* addr, int addrlen)
- 功能:connect()通常由
TCP类型客户端
调用,用来与服务器建立一个TCP连接,实际是发起3次握手过程 - 参数:
-
- sockfd:套接字描述符,指发起连接请求的套接字的描述符(一般是本地客户端的socket描述符)
-
- addr:传入参数,想要连接的服务器端地址信息,含IP地址和端口号
-
- addrlen:描述addr的大小
- 返回值:成功返回0,出错返回-1。
- 注意:
-
- 客户端需要调用connect()连接服务器,connect和bind的参数形式一致,区别在于bind的参数是自己的地址,而connect的参数是对方的地址。
-
- 当一个TCP客户端调用connect()函数时,如果没有绑定本地地址信息,则会自动绑定一个
(8)recvfrom接口
- 函数原型
#include <sys/types.h>
#include <sys/socket.h>
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,struct sockaddr* src_addr, socklen_t *addrlen);
- 功能:用于非可靠连接(UDP)的数据接收,即从一个套接字中获取信息
- 参数:
-
- sockfd:从该套接字获取信息
-
- buf:表示数据接收缓冲区
-
- len:以字节计算的接收缓冲区长度,即要接收数据的最大长度;
-
- flags:指定接收数据时使用的标志,通常为0,表示阻塞接收
-
- src_addr:一个输出型参数,获取到
对端的信息
(数据来源端的信息)有端口号
,IP等
,方便后序我们对其进行响应
- src_addr:一个输出型参数,获取到
-
- addrlen:对于UDP协议来说,它是一个
输入输出型参数
。输入时,表示src_addr结构体的长度;输出时,表示实际接收到的地址结构体的长度。
- addrlen:对于UDP协议来说,它是一个
- 返回值:成功则返回接收到的字符数,链接终止则返回0,失败则返回-1,
- 注意:
-
- 在
UDP协议
中,因为不存在“连接”的概念,所以每次接收数据都应该调用recvfrom()函数,以获取
发送端的IP地址和端口号
信息,以便进行相应
。
- 在
-
- 在
TCP协议
中,由于已经建立了连接
,所以可以使用recv()函数进行数据传输,不需要通过recvfrom获取对端信息,可以直接响应
。
- 在
-
- flags参数对于UDP协议而言没有太大的意义,因为UDP是无连接的协议,不存在TCP的流控、拥塞控制、超时重传等需要设置标志位的操作。所以在使用UDP协议时,通常将flags设置为0即可。
(9)sendto接口
- 函数原型
#include <sys/types.h>
#include <sys/socket.h>
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags, const struct sockaddr *dest_addr, socklen_t addrlen);
- 功能:用于非可靠连接(UDP)的数据发送,因为UDP方式为建立连接socket,因此需要指定目的协议地址。
- 参数
-
- sockfd:把数据写入到到该套接字
-
- buf:存放要发送数据的缓冲区
-
- len:发送数据大小(字节长度)
-
- flags:传输数据时使用的标志,通常为0。这些标志为UDP通信添加了一些不同的特性,以便不同业务场景使用
-
- dest_addr:一个输入形参数,是一个
sockaddr
类型的结构体指针,它包含了目标主机的IP地址和端口号等信息。需要根据实际协议调整结构体类型
,如IPv4协议使用sockaddr_in类型,IPv6协议使用sockaddr_in6类型。(需要自己创建一个sockaddr并填充“对端”对信息)
- dest_addr:一个输入形参数,是一个
-
- addrlen:dest_addr的大小,可设置为sizeof(struct sockaddr)
- 返回值:成功返回实际发送的字节数,失败返回-1,并设置error
- 注意:
-
- sendto()函数通常
用于UDP协议
中的数据传输。在UDP协议中,因为无“连接”
的概念,所以每次发送数据都需要指定
要发送到哪个IP地址和端口号
。因此,我们可以使用sendto()函数
向指定的IP地址和端口号发送数据报。
- sendto()函数通常
-
- 在
TCP协议
中,由于已经建立了连接,所以可以使用send()函数
进行数据传输。
- 在
-
- 当使用sendto()函数向远程主机发送数据时,如果目标主机是不可达的或者目标端口未打开,则会导致发送失败。为了避免这种情况发生,我们通常要首先检查目标主机和端口的可行性,再执行数据发送操作。
(10)recv接口
- 函数原型
#include <sys/types.h>
#include <sys/socket.h>
ssize_t recv(int sockfd, void* buf, int len, unsigned int flags)
- 功能:TCP类型的数据接收。也就是从已建立连接的套接字中接收数据
- 参数
-
- sockfd:接收端套接字描述符(非监听描述符);
-
- buf:接收缓冲区的基地址;
-
- len:以字节计算的接收缓冲区长度,即要接收数据的最大长度;
-
- flags:一般情况下置为0,表示阻塞模式;
- 返回值:失败时,返回值为-1;超时或对端主动关闭,返回值等于0;成功时,返回值是返回接收数据的长度。
- 注意:
-
- 需要注意的是,在使用recv()函数之前,需要
先调用accept()函数
来建立连接。如果套接字没有连接或者连接已经断开,调用recv()函数将会返回错误。
- 需要注意的是,在使用recv()函数之前,需要
-
- 此外,为了保证接收到完整的数据报,通常需要
多次调用recv()函数
,将接收到的数据依次存放到缓冲区
中。
- 此外,为了保证接收到完整的数据报,通常需要
-
- recv()函数的阻塞模式会
一直占用CPU资源
直到有数据到达或者出现错误,因此这种阻塞方式也被称为忙等待(Busy Waiting)。在忙等待期间,CPU会不断地执行recv()函数
,并检查是否有数据到达,这样会导致CPU负载过高,浪费大量的CPU时间片和电力资源。
- recv()函数的阻塞模式会
-
- 为了避免忙等待带来的负面影响,我们可以使用
非阻塞I/O
或者多路复用技术
- 为了避免忙等待带来的负面影响,我们可以使用
(11)send接口
- 函数原型
#include <sys/types.h>
#include <sys/socket.h>
ssize_t send(int socket, const void *buf, size_t len, int flags);
- 功能:TCP类型的数据发送。
- 参数:
-
- socket:指定发送端套接字描述符;
-
- buf:指明一个存放应用程序要发送数据的缓冲区;
-
- len:指明实际要发送的数据的字节数;
-
- flags:一般设置为0,表示在阻塞模式下发送;
- 返回值:失败时,返回值小于0;超时或对端主动关闭,返回值等于0;成功时,返回值是返回发送数据的长度。
- 注意:
-
- 需要注意的是,send函数并
不保证一次性发送所有数据
,可能需要多次调用才能将所有数据发送完毕。另外,send函数本身不能保证数据的可靠传输
,如果对数据的可靠性有严格要求,可以选择使用TCP协议或者其他可靠的网络传输协议。
- 需要注意的是,send函数并
-
- 在
使用TCP协议
时,因为数据的可靠性是由TCP协议保证的,所以即使在send函数无法一次性发送所有数据的情况下,数据也不会丢失。如果在调用send函数时返回的发送字节数小于要发送的字节数,可以重复调用send函数发送剩余的数据,直到所有数据都被发送完毕。
- 在
-
- 阻塞模式下进行发送。在阻塞模式下,当send函数调用时无法立即完成发送操作时,会一直等待,直到数据被发送成功或者发生错误。因此,在网络传输比较慢或者发送的数据量较大时,使用阻塞模式下的send函数会降低程序的运行效率。而非阻塞模式下的send函数(如使用MSG_DONTWAIT标志)可以使得send函数不会被阻塞,从而提高程序的性能。
3. 基于UDP协议的套接字程序
(1)服务端
套接字本质上也是一个文件描述符,指向的是一个“网络文件”。普通文件的文件缓冲区对应的是磁盘,数据先写入文件缓冲区,再刷新到磁盘,“网络文件”的文件缓冲区对应的是网卡,它会把文件缓冲区的数据刷新到网卡,然后发送到网络中。
创建一个套接字做的工作就是打开一个文件,接下来就是要将该文件和网络关联起来,这就是绑定的操作,完成了绑定,文件缓冲区的数据才知道往哪刷新。
<1> 服务器初始化
1. 创建套接字
代码如下:
2. 绑定端口
- 注意:
-
- 因为数据是要发送到网络中,所以要将主机序列的端口号转为网络序列的端口号
-
- 在使用UDP协议的服务端程序中,绑定IP地址和端口号是一个重要的步骤,这样服务端程序才能接受客户端发送的数据报。在绑定IP地址时,可以使用INADDR_ANY常量来代替具体的IP地址,表示绑定任意可用的IP地址。具体来说,INADDR_ANY是一个预定义的IPv4地址值,其值为0.0.0.0
-
- 使用INADDR_ANY绑定IP地址的好处在于,无论服务端所在的机器有多少个网络接口、使用了多少个IP地址,都可以通过绑定INADDR_ANY来让服务端程序监听所有网络接口(IP地址)上的指定端口号。这样,客户端就可以使用任意一台主机上的IP地址来与服务端通信,在发送数据报时,只需要将目标主机的IP地址设置为服务端所在机器的任意一个IP地址即可,也就是说服务端可以接收来自本主机任意IP地址的指定端口号接收到的数据。
什么是网络接口?
答:网络接口指的是网络上的硬件设备,例如网卡、无线网卡等。一个网络接口可以绑定一个或多个IP地址。在一个计算机上,可以有多个网络接口,每个网络接口都可以拥有一个或多个IP地址。
代码如下:
<2> 服务启动
- 注意:
-
- 客户端发出退出请求,只需要让客户端退出即可,服务端继续去读取其它客户端的请求即可,不能因为一个客户端退出,服务端就直接退出。
-
- 服务器一次读取数据失败也不可以直接退出,重新读取就好了
-
- 其中使用了popen函数,它是一个标准库函数,其功能是将一个外部命令作为另一个进程来执行,同时还可以通过数据流进行输入输出的交互。
代码如下:
<3> 服务端代码
这里我们采用命令行的方式获取我们服务器需要绑定的端口号,如果命令行参数格式输入错误,我们打印一个使用手册给用户即可。该服务端接收客户端发送的命令,在服务端创建子进行执行并将执行结果写入文件,然后读取文件中数据返回给客户端。
简化后代码如下:
#include <iostream> //输入输出
#include <stdlib.h>
#include <sys/types.h> //socket
#include <sys/socket.h> //socket
#include <arpa/inet.h> //IP地址转换
#include <netinet/in.h> //IP地址转换
#include <string> //字符串
#include <cerrno> //错误输出
#include <cstdio>
#include <cstring> //字符串函数
#define NUM 1024
std::string Usage(std::string proc)
{
std::cout<<"Usage "<<proc<<" port"<<std::endl;
return 0;
}
int main(int argc, char* argv[])
{
if(argc != 2)
{
Usage(argv[0]);
}
uint16_t UdpPort = atoi(argv[1]);
// 1 创建套接字
int sockfd = socket(AF_INET,SOCK_DGRAM,0);
if(sockfd < 0)
{
std::cerr<<"Fail to create socket"<<" errno ="<<errno<<std::endl;
return 1;
}
// 2. 绑定地址
struct sockaddr_in UdpServer_addr;
UdpServer_addr.sin_family = AF_INET;
UdpServer_addr.sin_port = htons(UdpPort);
UdpServer_addr.sin_addr.s_addr = INADDR_ANY; //任意地址
if(bind(sockfd,(struct sockaddr*)&UdpServer_addr,sizeof(UdpServer_addr)) < 0)
{
std::cerr<<"Fail to bind socket"<<" errno ="<<errno<<std::endl;
return 2;
}
// 3 提供服务
// 将客户端输入的命令在服务端执行一遍并返回结果给客户端
char recv_buf[NUM]; //接收缓冲区
while(true)
{
// 接收消息
struct sockaddr_in UdpClient_addr;
socklen_t len = sizeof(UdpClient_addr); //输入输出型参数
ssize_t ret = recvfrom(sockfd,recv_buf,sizeof(recv_buf)-1,0,(struct sockaddr*)&UdpClient_addr,&len);
if(ret < 0)
{
std::cerr<<"Fail to recvfrom"<<std::endl;
}
if(strcmp("exit",recv_buf)==0)
{
// 客户端退出时服务端不能执行退出命令
continue;
}
// 创建子进程执行命令,并将执行结果写入文件,然后读取结果,响应给客户端
if(ret > 0)
{
//将获取的消息作为字符串处理
recv_buf[ret] = '\0';
FILE* fp = popen(recv_buf,"r");
std::string str;
char line[NUM] = {0};
// 按行读取数据
while(fgets(line,sizeof(line),fp) != NULL)
{
str += line;
}
pclose(fp);
std::cout << "client# " << recv_buf<< std::endl;
sendto(sockfd,str.c_str(),str.size(),0,(struct sockaddr *)&UdpClient_addr,len);
}
}
return 0;
}
(2)客户端
<1> 客户端初始化
- 注意:
-
- 客户端初始化只需要创建套接字,不需要我们手动进行绑定,调用sendto时,会给我客户端分配一个端口号进行绑定,所以我们不需要手动绑定。
-
- 如果我们手动给客户端绑定了一个端口号,且该端口号已经被占用,就会绑定失败,所以让系统给我们的客户端分配一个端口号即可。
-
- 在标准的UDP实现中,客户端发送数据时可以选择指定源IP地址和端口号,如果未指定,则操作系统将自动绑定一个未使用的本地端口号和IP地址。这种自动绑定功能使得UDP客户端更加易于使用和灵活,因为它允许客户端专注于发送和接收数据报。
-
- 由于没有连接过程,客户端可以直接向目标主机的IP地址和端口号发送数据报,而不需要事先告诉任何人自己的IP地址和端口号。UDP不保存连接状态,因此无需维护连接记录。这使得UDP更加简单、快速、灵活,但也不太安全
代码如下:
<2> 客户端请求服务
- 注意:
-
- 客户端启动后进行发送数据,调用的是sendto接口,发送数据时,需要将自己的网络信息发送个对方,也就是用远端端口号和远端IP进行填充,所以需要在执行sendto函数之前就填写目标服务器信息,以便请求服务。
-
- 远端端口号需要转为网络字节序再进行发送,字符串IP需要使用addr转为整数
代码如下:
<3> 客户端代码
简化代码如下:
#include <iostream> //输入输出
#include <sys/types.h> //socket
#include <sys/socket.h> //socket
#include <arpa/inet.h> //IP地址转换
#include <netinet/in.h> //IP地址转换
#include <string> //字符串
#include <cerrno> //错误输出
#include <cstring> //字符串函数
#include <cstdlib> //atoi函数
#include <cstdio> //fgets函数
#include <stdio.h>
std::string Usage(std::string proc)
{
std::cout<<"Usage "<< proc <<" IP port"<<std::endl;
return 0;
}
int main(int argc, char* argv[])
{
if(argc != 3)
{
Usage(argv[0]);
return 0;
}
// 1. 创建套接字
int sockfd = socket(AF_INET,SOCK_DGRAM,0);
if(sockfd < 0)
{
std::cerr<<"Fail to create sock"<<std::endl;
return 1;
}
// 2. 绑定地址(自动绑定本地)
// 3. 填写目标服务器地址,以请求服务
struct sockaddr_in UdpServer_addr;
UdpServer_addr.sin_family = AF_INET;
UdpServer_addr.sin_port = htons(atoi(argv[2]));
UdpServer_addr.sin_addr.s_addr = inet_addr(argv[1]);
// 4. 使用服务
while(true)
{
std::cout<<"MyShell $";
char line[1024];
fgets(line, sizeof(line), stdin); //自动在末尾添加’\0‘
if(strcmp("exit",line)==0)
{
return 0;
}
sendto(sockfd,line,strlen(line),0,(struct sockaddr*)&UdpServer_addr,sizeof(UdpServer_addr));
struct sockaddr_in tmp;
socklen_t len = sizeof(tmp);
char recv_buf[1024];
// 发送完毕后阻塞式接收响应
ssize_t ret = recvfrom(sockfd,recv_buf,sizeof(recv_buf),0,(struct sockaddr*)&tmp,&len);
if(ret > 0)
{
recv_buf[ret] = 0;
std::cout << recv_buf << std::endl;
}
}
return 0;
}
(3)代码测试
<1> 本地环回地址
127.0.0.1
是IPv4的一个保留地址,也称为环回地址,是表示本地主机IP地址的一种方式,专门用于在本地计算机中进行内部通信测试的地址。
当要访问本机上正在运行的服务时,可以使用127.0.0.1作为目标地址,这个地址会被解析成本机本身的IP地址
,从而实现本机内部通信
,此时数据包不会被发送到网络上,而是直接送到本地计算机的网络协议栈中进行处理。
在进行网络编程时,我们可以将服务端绑定到127.0.0.1地址上,客户端就可以使用同样的地址来连接服务端。需要注意的是,127.0.0.1地址只能在本机上使用,无法通过网络传输到其他主机。如果需要通过网络连接其他主机,需要使用该主机的真实IP地址。也就是说如果我们在本地主机上运行一个服务,并将其绑定127.0.0.1地址上,那么只有在本主机上访问127.0.0.1地址才能够访问该服务
<2> 使用环回地址进行通信时数据包的流向
使用本地回环地址(127.0.0.1)进行通信时,数据包的流动过程并不会涉及真正的网络传输。下面是具体的流程:
- 应用层:应用程序创建套接字(socket),并通过套接字接口发送数据报给目标IP地址为127.0.0.1。
- 传输层:传输层(如TCP或UDP)接收到应用层的数据报,并在数据报头部增加自己的头部信息,形成段(segment)。
- 网络层:网络层(如IP)接收到段,并在段头部增加自己的头部信息,形成数据包(packet)。
- 链路层:链路层(如Ethernet)接收到数据包,并在数据包头部增加自己的头部信息,形成帧(frame)。
- 发送到回环接口:此时,帧被发送到本机的回环接口。回环接口是一种虚拟网络接口,它并不涉及真正的硬件设备,而是直接将帧传递回协议栈的上层。
- 回环:回环接口接收到帧后,解开帧的头部信息,并将数据包返回到网络协议栈的上层。这个过程相当于数据包“回到”了自己。
- 协议栈处理:数据包到达协议栈后,各个网络协议模块按照预定的顺序对数据包进行解析和处理。例如,传输层负责将数据包中的段解封装出来,然后将数据传递给应用层进行处理。
- 应用层:最终,应用程序通过套接字接口从协议栈上层接收到数据,并进行相应的处理。
需要注意的是,虽然使用本地回环地址进行通信时,数据包不会经过物理网络设备,但是数据包在网络协议栈中的流动过程不会因此发生改变。各个协议层次的处理仍然是必要的,以确保数据能够正确、可靠地传输和处理。
<3> 测试结果
可以使用下面的指令可以查看当前网络状态:
netstat [选项]
- n 拒绝显示别名,能显示数组尽量全部转化为数字
- l 仅列出在Listen状态下的服务状态
- p 显示建立相关链接的程序名
- t 显示tcp相关内容
- u 显示udp相关内容
- a 显示所以,不显示LISTEN相关
4. 基于TCP协议的套接字程序
(1)服务端
TCP协议的通信服务需要处理多个客户端的请求,也就是一个服务端需要服务多个不同的客户端,每个客户端连接都需要建立一个独立的执行流,比如一个进程或一个线程。如果只使用单个执行流来处理所有的连接请求,会有以下几个问题:
- 响应慢:如果有大量的连接同时到来,单个执行流可能无法及时处理,导致响应慢。
- 阻塞:在处理某个连接请求时,如果该请求需要耗费大量时间,单个执行流就会阻塞,无法及时响应其他请求。
- 容易崩溃:单个执行流的资源有限,不能承载过多的连接请求,一旦连接数量超过执行流的承载能力,就容易出现系统崩溃。
为了解决以上问题,服务端通常会使用多执行流
,即多线程或多进程来处理连接请求。多执行流能够充分利用系统的多核处理能力,将连接请求分配到不同的执行流中进行处理,从而提高系统的并发处理能力和吞吐量。
多执行流的实现方式有很多种,可以使用线程池
或进程池
等技术来管理执行流,避免频繁的创建和销毁执行流带来的开销,同时也可以控制执行流的数量,使得系统的资源利用率更高,性能更优。
当然可以使用单执行流来实现TCP通信,并且使用多路转接
可以避免单个执行流处理所以连接请求带来的问题,但是不能完全取代多执行流的方式,而这一技术在后文中会详细解释,所以这里的TCP服务端的编写分:多进程、多线程、线程池三个版本。
总之,基于TCP协议的通信服务,使用多执行流可以提高并发处理能力和系统吞吐量,同时也能增强系统的可靠性和容错性,是一种非常重要的设计技术
<1> 整体框架
封装一个类,来描述tcp服务端,成员变量包含服务器的地址信息
,服务器多监听套接字
。在构造函数中填写服务器地址信息,析构函数只需要关闭套接字文件即可
#define DEFAULT_PORT 8080
#define DEFAULT_BACKLOG 5
class TcpServer {
private:
int _listen_sock;
struct sockaddr_in _server_addr;
public:
public:
TcpServer(uint16_t port = DEFAULT_PORT, max_child_count = DEFAULT_CHILD_COUNT)
:_listen_sock(-1)
{
// 初始化服务器地址信息
_server_addr.sin_family = AF_INET;
_server_addr.sin_port = htons(port);
_server_addr.sin_addr.s_addr = INADDR_ANY;
}
~TcpServer()
{
if(_listen_sock != -1)
{
close(_listen_sock);
_listen_sock = -1;
}
delete[] _child_pids; //使用new分配数组内存时要使用delete[]来释放
}
}
<2> 服务器初始化
- 创建套接字(socket):TCP是面向连接的,所以第二个参数和TCP是不同的,填的是
SOCK_STREAM
,其它两个参数是一样的,协议家族填AF_INET
,协议类别填0
。返回一个套接字。 - 绑定端口号(bind):需要填充
struct sockaddr_in
这个结构体,里面有协议家族
,端口号
和IP
,端口号根据用户传参进行填写,IP直接绑定INADDR_ANY
。以便客户端访问。 - 设置监听套接字(listen):服务器处于
被动等待状态
,即它只接受
客户端发来的连接请求,不主动
发起连接。因此,服务器程序必须先将套接字设置为监听状态
,以便开始接受客户端的连接请求。并指定一个等待连接请求的队列的最大长度。这个队列存储着客户端发来的连接请求,它的大小通常是有限制的,超出队列长度的请求将被拒绝。 - 循环获取连接(accept):从
等待连接请求
的队列
中取出一个请求,并创建一个新的套接字与客户端通信。这个新的套接字用于与该客户端与服务端进行通信。每获取一个新的连接,就可以创建一个子进程或子线程来进行双方通信,处理业务,具体服务方式后面细说。
void loop()
{
struct sockaddr_in peer;// 获取远端端口号和ip信息
socklen_t len = sizeof(peer);
while (1){
// 获取链接
// sock 是进行通信的一个套接字 _listen_sock 是进行监听获取链接的一个套接字
int sock = accept(_listen_sock, (struct sockaddr*)&peer, &len);
if (sock < 0)
{
// 一次获取连接失败不要直接将服务端关闭,而是重新去获取连接
std::cout << "accept fail, continue accept" << std::end
continue;
}
// 提供服务 service 后面介绍
}
}
bool TcpServerInit()
{
// 1.创建监听套接字
_listen_sock = socket(AF_INET,SOCK_STREAM,0);
if (m_listenFd < 0) {
cerr << "Failed to create listen socket." << endl;
return 1;
}
// 2.绑定端口号
if(bind(_listen_sock,_server_addr,(socklen_t)sizeof(sizeof(_server_addr))) < 0)
{
cerr << "Failed to bind address and port." << endl;
return 2;
}
// 3.将套接字设置为监听套接字
if(listen(_listen_sock, DEFAULT_BACKLOG) < 0)
{
cerr << "Failed to start listening." << endl;
return 3;
}
// 4.循环接收连接并创建子进程进行业务处理
loop();
}
(2)客户端
<1> 整体框架
和服务端一样,封装一个类描述,类成员有服务端地址信息以及自身套接字,代码如下:
#define DEFAULT_SERVER_IP "127.0.0.1"
#define DEFAULT_SERVER_PORT 8080
class TcpClient {
private:
int _sock;
struct sockaddr_in _serv_addr;
public:
TcpClient(string server_ip = DEFAULT_SERVER_IP, uint16_t server_port = DEFAULT_SERVER_PORT)
:sock(-1)
{
// 设置服务器地址
memset(&m_serv_addr, 0, sizeof(_serv_addr));
_serv_addr.sin_family = AF_INET;
_serv_addr.sin_addr.s_addr = inet_addr(server_ip.c_str());
_serv_addr.sin_port = htons(server_port);
}
~TcpClient()
{
if (_sock >= 0)
close(_sock);
}
}
<2> 客户端初始化
创建套接字(socket):TCP是面向连接的,所以第二个参数和TCP是不同的,填的是SOCK_STREAM
,其它两个参数是一样的,协议家族填AF_INET
,协议类别填0
。返回一个套接字。
注意:客户端的初始化只需要创建套接字即可,不需要绑定端口号,发起连接请求的时候,会自动给客户端分配一个端口号。创建套接字和服务端是一样的
bool TcpClientInit()
{
// 1.创建socket
_sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (_sock == -1) {
std::cerr << "Error: Failed to create socket." <<std:: endl;
return false;
}
return true;
}
<4> 发起连接请求
发起连接请求(connect):想服务端发起连接请求,注意,调用这个函数之前,需要先填充好服务端的信息,有协议家族、端口号和IP,请求连接失败直接退出进程,重新启动进程即可,连接成功之后就可以像服务端发起各自的服务请求(后面介绍)
void TcpClientConnect()
{
// 当一个TCP客户端调用connect()函数时,如果没有绑定本地地址信息,则会自动绑定一个
if (connect(_sock, (struct sockaddr*)&_serv_addr, sizeof(_serv_addr)) < 0)
{
// 连接失败
std::cerr << "connect fail" << std::endl;
exit(-1);
}
}
<3> 发起服务请求
代码如下:
void Request()
{
while (1)
{
std::cout << "Client # ";
char line[1024];
fgets(line, sizeof(line), stdin); //自动在末尾添加’\0‘
send(_sock, line, strlen(line), 0);
char recv_buf[1024];
ssize_t size = recv(_sock, recv_buf, sizeof(recv_buf), 0);
if (size <= 0){
std::cerr << "read error" << std::endl;
exit(-1);
}
recv_buf[size] = 0;
std::cout << recv_buf << std::endl;
}
}
(3)不同版本服务端服务代码
<1> 多进程版本
注意: 为了给不同的连接提供服务,所以我们需要让父进程去不断获取连接,获取连接后,让父进程创建一个子进程去为这个获取到的连接提供服务,那么子进程去服务连接,但是正常情况下子进程退出是需要父进程进行等待的,否则如果不等待的话,子进程退出后就会由于资源无法回收而变成僵尸进程了。如果父进程等待子进程的话,非阻塞轮询等待比较麻烦,而父进程阻塞等待必须等待子进程退出后才能执行,这就变成了串行的执行,使用多执行流就没有意义了,所以就有了一下两种解决方案:
- 使用信号处理函数:当子进程终止时,会向父进程发送
SIGCHLD信号
,主进程注册SIGCHLD信号
,把它的处理信号的方式改成SIG_IGN(忽略)
,此时子进程退出就会自动清理资源不会产生僵尸进程,也不会通知父进程 - 通过创建子进程,子进程创建孙子进程,子进程直接退出,于是孙子进程就变成了孤儿进程,此时
init进程
会领养孤儿进程
,并且init进程成为孤儿进程的父进程。在这种情况下,当子进程结束时,它会向init进程发送一个SIGCHLD信号,告知init进程
可以回收子进程
的资源了。这样原本的父进程只需要等很短的时间就可以回收子进程的资源,这样原本的父进程可以继续去获取连接,孙子进程给连接提供服务即可
注意:父进程创建好子进程之后,子进程可以将监听套接字关闭,此时该套接字对子进程来说是没有用的,当然也可以不用关闭,没有多大的浪费。但父进程关闭掉服务sock是有必要的,因为此时父进程不需要维护这些套接字了,子进程或孙子进程
维护即可;如果不关闭,且有很多客户端向服务端发起请求,那么父进程这边就要维护很多不必要的套接字,让父进程的文件描述符不够用,造成文件描述符泄漏
,所以父进程关闭服务套接字是必须的。
方法一代码如下:
// 循环获取连接
void loop()
{
// 对SIGCHLD信号进行注册,处理方式为忽略
signal(SIGCHLD, SIG_IGN);
struct sockaddr_in peer;// 获取远端端口号和ip信息
socklen_t len = sizeof(peer);
while (1)
{
// 获取链接
// sock 是进行通信的一个套接字 _listen_sock 是进行监听获取链接的一个套接字
int sock = accept(_listen_sock, (struct sockaddr*)&peer, &len);
if (sock < 0)
{
// 一次获取连接失败不要直接将服务端关闭,而是重新去获取连接
std::cout << "accept fail, continue accept" << std::end
continue;
}
// 创建子进程
pid_t id = fork();
if (id == 0)
{
// 子进程
close(_listen_sock); // 可以不关闭,但是建议关闭,以防后期子进程对监听套接字fd进行了一些操作,对父进程造成影响
int peer_Port = ntohs(peer.sin_port);
std::string peer_IP = inet_ntoa(peer.sin_addr);
std::cout << "get a new link, [" << peer_IP << "]:[" << peer_Port << "]"<< std::endl;
// 子进程想客户端(peer)提供服务
Service(peer_IP, peer_Port, sock);
// 服务完成后关闭套接字文件,并退出,在Service中完成
}
// 父进程继续去获取连接
}
}
void Service(std::string ip, int port, int sock)
{
while (1)
{
char recv_buf[256];
ssize_t size = recv(sock, recv_buf, sizeof(recv_buf), 0);
if (size > 0)
{
// 正常读取size字节的数据
buf[size] = 0;
std::cout << "[" << ip << "]:[" << port << "]# "<< recv_buf << std::endl;
std::string msg = "server get!-> ";
msg += buf;
send(sock, msg.c_str(), msg.size(), 0);
}
else if (size == 0)
{
// 对端关闭
std::cout << "[" << ip << "]:[" << port << "]# close" << std::endl;
break;
}
else
{
// 出错
std::cerr << sock << "read error" << std::endl;
break;
}
}
close(sock);
std::cout << "service done" << std::endl;
// 子进程退出
exit(0);
}
测试
我们测试到该基于TCP套接字程序可以正常进行通信:
略微修改代码,查看在关闭文件描述符后,发现每一个客户端连接到服务器我们都可以看到新sock是4,因为父进程创建完子进程后将文件描述符关闭了,这样就可以将父进程的文件描述符控制在一定范围中,除非同时有大量用户连接
当客户端每一次连接服务器是,服务端(父进程)就会给客户端创建分配一个sock套接字,并创建一个子进程,如果父进程不关闭新创建的sock描述符,那么每一次客户端连接时到sock的值都会是不同的。
方法二代码如下:
void loop()
{
struct sockaddr_in peer;// 获取远端端口号和ip信息
socklen_t len = sizeof(peer);
while (1)
{
// 获取链接
// sock 是进行通信的一个套接字 _listen_sock 是进行监听获取链接的一个套接字
int sock = accept(_listen_sock, (struct sockaddr*)&peer, &len);
if (sock < 0)
{
std::cout << "accept fail, continue accept" << std::endl;
continue;
}
// 创建子进程
pid_t id = fork();
if (id == 0)
{
// 子进程
// 父子进程的文件描述符内容一致
// 子进程可以关闭监听套接字的文件描述符
close(_listen_sock); // 可以不关闭,但是建议关闭,以防后期子进程对监听套接字fd进行了一些操作,对父进程造成影响
if (fork() > 0)
{
// 子进程直接退出,让孙子进程被OS(1号进程)领养,退出时资源被操作系统回收
exit(0);
}
// 孙子进程
uint16_t peer_Port = ntohs(peer.sin_port);
std::string peer_IP = inet_ntoa(peer.sin_addr);
std::cout << "get a new link, [" << peerIp << "]:[" << peer_Port << "]"<< std::endl;
Service(peer_IP, peer_Port, sock);
}
// 主进程关闭sock 如果不关闭,那么爷爷进程可用文件描述符会越来越少
close(sock);
// 爷爷进程等儿子进程
waitpid(-1, nullptr, 0);// 以阻塞方式等待,但这里不会阻塞,因为儿子进程是立即退出的
}
}
void Service(std::string ip, int port, int sock)
{
while (1)
{
char recv_buf[256];
ssize_t size = recv(sock, recv_buf, sizeof(recv_buf),0);
if (size > 0)
{
// 正常读取size字节的数据
buf[size] = 0;
std::cout << "[" << ip << "]:[" << port << "]# "<< buf << std::endl;
std::string msg = "server get!-> ";
msg += buf;
write(sock, msg.c_str(), msg.size());
}
else if (size == 0)
{
// 对端关闭
std::cout << "[" << ip << "]:[" << port << "]# close" << std::endl;
break;
}
else
{
// 出错
std::cerr << sock << "read error" << std::endl;
break;
}
}
close(sock);
std::cout << "service done" << std::endl;
// 孙子进程退出
exit(0);
}
测试
我们测试到该基于UDP套接字程序可以正常进行通信:
这里就置测试第二种写法,下面是一段监控脚本,监控有多少进程在运行:
while :; do ps axj | head -1 && ps axj | grep TcpService | grep -v grep; echo "##############################"; sleep 1; done
由于子进程一创建就被退出了,所以并没有观察到子进程,但是确实存在孙子进程在运行,并且它的父进程是1号进程,退出是由系统自动回收资源,不需要原本的父进程阻塞等待它
服务端代码如下
#include <iostream>
#include <unistd.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <cstring> //memset内存处理函数
#include <stdint.h> //unit16_t类型
#include <signal.h>
#include <stdlib.h> //atoi函数
#include <errno.h>
#include <sys/wait.h> //waitpid函数
#define DEFAULT_PORT 8080
// #define DEFAULT_CHILD_COUNT 10
#define DEFAULT_BACKLOG 5
#define WAY 1
class TcpServer{
private:
int _listen_sock;
struct sockaddr_in _server_addr;
// pid_t* _child_pids;
// int _max_child_count;
#if WAY
// 服务代码:接收客户端发送的数据,并打印IP和Port以及数据
// 并告诉客户端服务端已经接收到数据
void Service(std::string ip, int port, int sock)
{
while (1)
{
char recv_buf[256];
ssize_t size = recv(sock, recv_buf, sizeof(recv_buf), 0);
if (size > 0)
{
// 正常读取size字节的数据
recv_buf[size] = 0;
std::cout << "[" << ip << "]:[" << port << "]# "<< recv_buf << std::endl;
std::string msg = "server get!-> ";
msg += recv_buf;
send(sock, msg.c_str(), msg.size(), 0);
}
else if (size == 0)
{
// 对端关闭
std::cout << "[" << ip << "]:[" << port << "]# close" << std::endl;
break;
}
else
{
// 出错
std::cerr << sock << "read error" << std::endl;
break;
}
}
close(sock);
std::cout << "service done" << std::endl;
// 子进程退出
exit(0);
}
// 循环获取连接
void loop()
{
// 对SIGCHLD信号进行注册,处理方式为忽略
signal(SIGCHLD, SIG_IGN);
struct sockaddr_in peer;// 获取远端端口号和ip信息
socklen_t len = sizeof(peer);
while (1)
{
// 获取链接
// sock 是进行通信的一个套接字 _listen_sock 是进行监听获取链接的一个套接字
int sock = accept(_listen_sock, (struct sockaddr*)&peer, &len);
if (sock < 0)
{
// 一次获取连接失败不要直接将服务端关闭,而是重新去获取连接
std::cout << "accept fail, continue accept" << std::endl;
continue;
}
// 创建子进程
pid_t id = fork();
if (id == 0)
{
// 子进程
close(_listen_sock); // 可以不关闭,但是建议关闭,以防后期子进程对监听套接字fd进行了一些操作,对父进程造成影响
uint16_t peer_Port = ntohs(peer.sin_port);
std::string peer_IP = inet_ntoa(peer.sin_addr);
std::cout << "get a new link, [" << peer_IP << "]:[" << peer_Port << "]"<< std::endl;
// 子进程想客户端(peer)提供服务
Service(peer_IP, peer_Port, sock);
// 服务完成后关闭套接字文件,并退出,在Service中完成
}
// 父进程继续去获取连接
close(sock);
}
}
#else
void Service(std::string ip, int port, int sock)
{
while (1)
{
char recv_buf[256];
ssize_t size = recv(sock, recv_buf, sizeof(recv_buf),0);
if (size > 0)
{
// 正常读取size字节的数据
recv_buf[size] = 0;
std::cout << "[" << ip << "]:[" << port << "]# "<< recv_buf << std::endl;
std::string msg = "server get!-> ";
msg += recv_buf;
send(sock, msg.c_str(), msg.size(), 0);
}
else if (size == 0)
{
// 对端关闭
std::cout << "[" << ip << "]:[" << port << "]# close" << std::endl;
break;
}
else
{
// 出错
std::cerr << sock << "read error" << std::endl;
break;
}
}
close(sock);
std::cout << "service done" << std::endl;
// 孙子进程退出
exit(0);
}
void loop()
{
struct sockaddr_in peer;// 获取远端端口号和ip信息
socklen_t len = sizeof(peer);
while (1)
{
// 获取链接
// sock 是进行通信的一个套接字 _listen_sock 是进行监听获取链接的一个套接字
int sock = accept(_listen_sock, (struct sockaddr*)&peer, &len);
if (sock < 0)
{
std::cout << "accept fail, continue accept" << std::endl;
continue;
}
// 创建子进程
pid_t id = fork();
if (id == 0)
{
// 子进程
// 父子进程的文件描述符内容一致
// 子进程可以关闭监听套接字的文件描述符
close(_listen_sock); // 可以不关闭,但是建议关闭,以防后期子进程对监听套接字fd进行了一些操作,对父进程造成影响
if (fork() > 0)
{
// 子进程直接退出,让孙子进程被OS(1号进程)领养,退出时资源被操作系统回收
exit(0);
}
// 孙子进程
uint16_t peer_Port = ntohs(peer.sin_port);
std::string peer_IP = inet_ntoa(peer.sin_addr);
std::cout << "get a new link, [" << peer_IP << "]:[" << peer_Port << "]"<< std::endl;
Service(peer_IP, peer_Port, sock);
}
// 关闭sock 如果不关闭,那么爷爷进程可用文件描述符会越来越少
close(sock);
// 爷爷进程等儿子进程
waitpid(-1, NULL, 0);// 以阻塞方式等待,但这里不会阻塞,因为儿子进程是立即退出的
}
}
#endif
public:
TcpServer(uint16_t port = DEFAULT_PORT)
:_listen_sock(-1)
// ,_max_child_count = max_child_count
{
// 初始化服务器地址信息
_server_addr.sin_family = AF_INET;
_server_addr.sin_port = htons(port);
_server_addr.sin_addr.s_addr = INADDR_ANY;
//_child_pids = new pid_t[_max_child_count];
//memset(_child_pids,0,sizeof(_child_pids));
}
~TcpServer()
{
if(_listen_sock != -1)
{
close(_listen_sock);
_listen_sock = -1;
}
// delete[] _child_pids; //使用new分配数组内存时要使用delete[]来释放
}
bool TcpServerInit()
{
// 1.创建监听套接字
_listen_sock = socket(AF_INET,SOCK_STREAM,0);
if (_listen_sock < 0) {
std::cerr << "Failed to create listen socket." << std::endl;
return 1;
}
// 2.绑定端口号
if(bind(_listen_sock,(struct sockaddr*)&_server_addr,(socklen_t)sizeof(_server_addr)) < 0)
{
std::cerr << "Failed to bind address and port." << std::endl;
std::cerr <<"errno"<<errno<<std::endl;
return 2;
}
// 3.将套接字设置为监听套接字
if(listen(_listen_sock, DEFAULT_BACKLOG) < 0)
{
std::cerr << "Failed to start listening." << std::endl;
return 3;
}
// 4.循环接收连接并创建子进程进行业务处理
loop();
}
};
int main(int argc, char* argv[])
{
if(argc != 2)
{
std::cout << "Usage: " << argv[0] << " port" << std::endl;
return 1;
}
TcpServer* TS = new TcpServer(atoi(argv[1]));
TS->TcpServerInit();
delete TS;
return 0;
}
客户端代码如下
#include <iostream>
#include <unistd.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <cstring> //memset内存处理函数
#include <stdint.h> //unit16_t类型
#include <cerrno> //错误输出
#include <string>
#include <stdlib.h> //exit函数
#include <cstdio> //fgets函数
#include <stdio.h> //stdin
#define DEFAULT_SERVER_IP "127.0.0.1"
#define DEFAULT_SERVER_PORT 8080
// using namespace std;
class TcpClient{
private:
int _sock;
// string _server_ip;
// uint16_t _server_port;
struct sockaddr_in _serv_addr;
public:
TcpClient(std::string server_ip = DEFAULT_SERVER_IP, uint16_t server_port = DEFAULT_SERVER_PORT)
//:_server_ip(server_ip), _server_port(server_port)
:_sock(-1)
{
// 设置服务器地址
memset(&_serv_addr, 0, sizeof(_serv_addr));
_serv_addr.sin_family = AF_INET;
_serv_addr.sin_addr.s_addr = inet_addr(server_ip.c_str());
_serv_addr.sin_port = htons(server_port);
}
~TcpClient()
{
if (_sock >= 0)
close(_sock);
}
bool TcpClientInit()
{
// 1.创建socket
_sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (_sock == -1) {
std::cerr << "Error: Failed to create socket." <<std:: endl;
return false;
}
return true;
}
void TcpClientConnect()
{
// 当一个TCP客户端调用connect()函数时,如果没有绑定本地地址信息,则会自动绑定一个
if (connect(_sock, (struct sockaddr*)&_serv_addr, sizeof(_serv_addr)) < 0)
{
// 连接失败
std::cerr << "connect fail" << std::endl;
exit(-1);
}
}
void Request()
{
while (1)
{
std::cout << "Client # ";
char line[1024];
fgets(line, sizeof(line), stdin); //自动在末尾添加’\0‘
send(_sock, line, strlen(line), 0);
char recv_buf[1024];
ssize_t size = recv(_sock, recv_buf, sizeof(recv_buf), 0);
if (size <= 0){
std::cerr << "read error" << std::endl;
exit(-1);
}
recv_buf[size] = 0;
std::cout << recv_buf << std::endl;
}
}
};
int main(int argc, char* argv[])
{
if(argc != 3)
{
std::cout<<"Usage"<<argv[0]<<" IP "<<" port "<<std::endl;
return 1;
}
TcpClient* TC = new TcpClient(argv[1], atoi(argv[2]));
TC->TcpClientInit();
TC->TcpClientConnect();
TC->Request();
delete TC;
return 0;
}
<2> 多线程版本
通过创建一个线程为客户端提供服务,创建好的线程之间进行线程分离,这样主线程就不需要等待其它线程了
注意:当主线程创建号子线程后,主线程不能像上文父进程一样关闭新创建的sock描述符,子线程也不能关闭监听sock。因为对于父子进程来说,子进程会继承父进程的file_struct
,他们有独立且相同的文件描述符,并且他们的文件描述符指向同一个文件
,子进程关闭一个文件时,只是断开了子进程与该文件的“接口”
,所以父进程关闭文件描述符对子进程不会产生影响。而由于父子线程共享同一个file_struct
,当主线程关闭sock描述符时,子进程也就无法访问该sock套接字文件了
所以子线程不需要关闭_listen_sock套接字,主线程也不需要关闭new_sock套接字,只需要在子线程服务完毕后,将自己所使用的new_sock套接字关闭!这样不会造成文件描述符泄露!
注意:在创建子线程的时候,子线程执行的函数只可以传递一个参数过去,但是我们想要子线程执行Service函数,该函数需要三个参数,分别是客户端端口号,客户端IP地址和新创建的套接字文件,为了只传递一个参数,可以将Service所需的三个参数封装为一个类进行传入。
// 创建一个类给thread_run传输
class Info{
public:
std::string _IP;
uint16_t _port;
int _sock;
Info(int port, std::string ip, int sock)
:_port(port)
,_IP(ip)
,_sock(sock)
{}
}
注意:一个线程是一个独立的执行流程,它通常不应该依赖于任何对象或数据。因此,线程入口函数必须是一个独立的函数,不能依赖于任何对象和数据。如果线程入口函数是一个非静态的成员函数,则需要通过对象来调用,这意味着每个线程必须拥有自己独立的对象,这会使线程管理变得复杂,并增加了线程之间共享数据的难度。
在C++中,非静态成员函数需要通过实例对象来调用,而线程入口函数不能通过对象来调用。因此,如果我们想要在类中使用pthread_create()
函数创建线程,就需要将线程入口函数
声明为静态函数
,这样才能直接传递给pthread_create()函数。
由于 pthread_run()
变成了静态函数,由于静态成员函数无法调用非静态成员函数(本质是因为静态成员函数无法传递this指针),为了让创建出来的线程线程就可以调用该Service
函数,这里将Service
函数也用static
修饰
static void* thread_run(void* arg)
{
// 线程分离
pthread_detach(pthread_self());
Info info = *(Info*)arg;
delete (Info*)arg;
Service(info._IP, info._port, info._sock);
return NULL;
}
void loop()
{
struct sockaddr_in peer; //注意不能写为sockaddr
socklen_t len = sizeof(peer);
while(1)
{
// 获取连接
int new_sock = accept(_listen_sock, (struct sockaddr)&peer, &len);
if(new_sock < 0)
{
std::cout<<"Fail to accept"<<std::endl;
}
// 创建子线程
phtread_t tid;
uint16_t peer_Port = ntohs(peer.sin_port);
std::string peer_IP = inet_ntoa(peer.sin_addr);
Info* info = new Info(peer_Port, peer_IP, new_sock); // 要在子线程中释放这块空间
pthread_create(&tid, NULL, thread_run, (void*)info);
}
}
// 为了防止给子线程传递this指针,将其改为stastic
// 并且由于子线程函数只能接受一个参数,所以我们将Service函数的参数分装为一个类来进行传输
static void Service(std::string ip, int port, int sock)
{
while (1)
{
char recv_buf[256];
ssize_t size = recv(sock, recv_buf, sizeof(recv_buf), 0);
if (size > 0)
{
// 正常读取size字节的数据
recv_buf[size] = 0;
std::cout << "[" << ip << "]:[" << port << "]# "<< recv_buf << std::endl;
std::string msg = "server get!-> ";
msg += recv_buf;
send(sock, msg.c_str(), msg.size(), 0);
}
else if (size == 0)
{
// 对端关闭
std::cout << "[" << ip << "]:[" << port << "]# close" << std::endl;
break;
}
else
{
// 出错
std::cerr << sock << "read error" << std::endl;
break;
}
}
close(sock);
std::cout << "service done" << std::endl;
// exit(0); 该操作会直接终止线程
}
测试
多线程实现的基于TCP的套接字程序可以正常运行:
为了方便测试,这里也写了一个监控脚本,监控线程数,如下:
while :; do ps -aL | head -1 && ps -aL | grep TcpServer | grep -v grep; echo "#################################"; sleep 1; done
服务端启动,有一个主线程,客户端启动后多了一个线程
<3> 线程池版本
上文中的俩个版本实现基于TCP的套接字程序可以实现基本的通信任务,但是这俩个版本代码仍然存在2个问题
:
- 服务端创建线程或进程无上限,但是进程或线程并不是越多效率就越高。因为当线程比较少是,服务端处理任务是主要的消耗,而当线程比较多是,线程之间的切换就变成了主要的消耗(理解:增加了额外的调度和切换开销,而可用的总资源量不变)。所以当执行流非常多是,服务端推进是非常慢的,有可能导致服务器无法对外正常进行服务。
- 服务端在接受到请求后才会创建线程/进程,如果同时有大量的连接请求,会导致大量的线程被创建和销毁,造成系统资源浪费,导致系统性能下降;并且服务端只有在接受到请求后才创建线程/进程,会给用户添加了创建线程的时间成本,服务启动就会变慢,造成用户体验不佳。
使用线程池就可以解决上述俩个问题:
- 使用线程池可以根据实际需要调整线程数量,避免由于过多线程占用系统资源导致系统崩溃的情况发生,从而减少资源占用
- 使用线程池可以将线程的创建和销毁开销降到最低,通过复用线程的方式来处理多个连接,提高程序的并发性能。
当使用线程池时,服务端将自己的服务和客户端的地址信息封装为一个类,当有客户请求服务时,就将服务类push给线程池;主线程继续accept新的连接。
线程池在上一篇博客中详细解释过,线程池处理任务的步骤如下:当主线程获取一个新的套接字,服务端就会构建一个新的任务对象,然后通过单里将任务对象push到任务队列中,该任务对象在执行成员函数run可以给客户端提供服务。当首次调用单例的时候会调用Getinstance来创建一个线程池,然后调用pushTask将任务送入任务队列中。当任务队列中有任务后,线程池中线程就会处理该任务;没有任务就让线程在条件变量下等待,有任务就拿出来,然后执行任务,该线程处理完任务后继续循环等待下一个任务。
#pragma once
#include <iostream>
#include <string>
#include <queue>
#include <unistd.h>
#include <pthread.h>
namespace ns_threadpool
{
const int g_num = 5;
template <class T>
class ThreadPool
{
private:
int num_;
std::queue<T> task_queue_; //该成员是一个临界资源
pthread_mutex_t mtx_;
pthread_cond_t cond_;
static ThreadPool<T> *ins;
private:
// 构造函数必须得实现,但是必须的私有化
ThreadPool(int num = g_num) : num_(num)
{
pthread_mutex_init(&mtx_, nullptr);
pthread_cond_init(&cond_, nullptr);
}
ThreadPool(const ThreadPool<T> &tp) = delete;
//赋值语句
ThreadPool<T> &operator=(ThreadPool<T> &tp) = delete;
public:
static ThreadPool<T> *GetInstance()
{
static pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
// 当前单例对象还没有被创建
if (ins == nullptr) //双判定,减少锁的争用,提高获取单例的效率!
{
pthread_mutex_lock(&lock);
if (ins == nullptr)
{
ins = new ThreadPool<T>();
ins->InitThreadPool();
std::cout << "首次加载对象" << std::endl;
}
pthread_mutex_unlock(&lock);
}
return ins;
}
void Lock()
{
pthread_mutex_lock(&mtx_);
}
void Unlock()
{
pthread_mutex_unlock(&mtx_);
}
void Wait()
{
pthread_cond_wait(&cond_, &mtx_);
}
void Wakeup()
{
pthread_cond_signal(&cond_);
}
bool IsEmpey()
{
return task_queue_.empty();
}
public:
// 在类中要让线程执行类内成员方法,是不可行的!
// 必须让线程执行静态方法
static void *Rountine(void *args)
{
pthread_detach(pthread_self());
ThreadPool<T> *tp = (ThreadPool<T> *)args;
while (true)
{
tp->Lock();
while (tp->IsEmpey())
{
//任务队列为空,线程该做什么呢??
tp->Wait();
}
//该任务队列中一定有任务了
T t(0," ",0); //注意这里!!!
tp->PopTask(&t);
tp->Unlock();
t.Run();
}
}
void InitThreadPool()
{
pthread_t tid;
for (int i = 0; i < num_; i++)
{
pthread_create(&tid, nullptr, Rountine, (void *)this /*?*/);
}
}
void PushTask(const T &in)
{
Lock();
task_queue_.push(in);
Unlock();
Wakeup();
}
void PopTask(T *out)
{
*out = task_queue_.front();
task_queue_.pop();
}
~ThreadPool()
{
pthread_mutex_destroy(&mtx_);
pthread_cond_destroy(&cond_);
}
};
template <class T>
ThreadPool<T> *ThreadPool<T>::ins = nullptr;
}
单独写一个头文件——Task.hpp
,其中有任务类
,任务类里面有三个成员变量,也就是端口号,IP和套接字,其中有一个成员方法——Run
,里面封装了一个Service函数
,也就是前面写的,把它放在Task.hpp
这个头文件下,线程池里面的线程执行run函数即可,头文件内容如下:
#pragma once
#include <iostream>
#include <string>
#include <unistd.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <errno.h>
#include <stdint.h>
struct Task
{
int _port;
std::string _ip;
int _sock;
Task(int port, std::string ip, uint16_t sock)
:_port(port)
,_ip(ip)
,_sock(sock)
{}
static void Service(std::string ip, int port, uint16_t sock)
{
while (1)
{
char recv_buf[256];
ssize_t size = recv(sock, recv_buf, sizeof(recv_buf), 0);
if (size > 0)
{
// 正常读取size字节的数据
recv_buf[size] = 0;
std::cout << "[" << ip << "]:[" << port << "]# "<< recv_buf << std::endl;
std::string msg = "server get!-> ";
msg += recv_buf;
send(sock, msg.c_str(), msg.size(), 0);
}
else if (size == 0)
{
// 对端关闭
std::cout << "[" << ip << "]:[" << port << "]# close" << std::endl;
break;
}
else
{
// 出错
std::cerr << sock << "read error" << std::endl;
break;
}
}
close(sock);
std::cout << "service done" << std::endl;
}
void Run()
{
Service(_ip, _port, _sock);
}
};
服务端核心代码如下:
#include "threa_poop.hpp"
#include "Task.hpp"
using namespace ns_threadpool;
void loop()
{
struct sockaddr_in peer;// 获取远端端口号和ip信息
socklen_t len = sizeof(peer);
while (1)
{
// 获取链接
// sock 是进行通信的一个套接字 _listen_sock 是进行监听获取链接的一个套接字
int sock = accept(_listen_sock, (struct sockaddr*)&peer, &len);
if (sock < 0)
{
std::cout << "accept fail, continue accept" << std::endl;
continue;
}
int peer_Port = ntohs(peer.sin_port);
std::string peer_IP = inet_ntoa(peer.sin_addr);
std::cout << "get a new link, [" << peer_IP << "]:[" << peer_Port << "]"<< std::endl;
Task task(peer_Port, peer_IP, sock);
ThreadPool<Task>::GetInstance()->PushTask(task);
}
}
测试
线程池实现的基于TCP的套接字程序可以正常运行:
继续监测线程变化
while :; do ps -aL | head -1 && ps -aL | grep main_server | grep -v grep; echo "#################################"; sleep 1; done
可以看到的是,不论服务端有多少个连接,都只有5个线程在为这些连接提供服务,这就很好地展示处理线程池带来的价值,不会频繁创建和销毁消除,不造成资源浪费,是一种不错的选择。
5. 浅谈TCP通信过程和socket API的关系
下面是TCP建立连接三次握手的过程和断开连接四次挥手的过程:
图中介绍了相关接口调用与实际通信对应的动作,详细动作后面介绍