客户端与服务器的区别
服务器和客户端有什么区别呢
?
根据用途
,
服务器可以分为很多种类,
其硬件和操作系统与客户端是有所不同的
。
但是
,
网络相关的部分
, 如网卡、
协议栈
、
Socket
库等功能和客户端却并无二致
。
无论硬件和
OS
如何变化,
TCP
和
IP
的功能都是一样的
,
或者说这些功能规格都是统一的
。 不过,
它们的功能相同
,
不代表用法也相同
。
在连接过程中
,
客户端发起连接操作,
而服务器则是等待连接操作
,
因此在
Socket
库的用法上还是有一些区别的,
即应用程序调用的
Socket
库的程序组件不同
。此外,
服务器的程序可以同时和多台客户端计算机进行通信
,
这也是一点区别。
因此
,
服务器程序和客户端程序在结构上是不同的
。
服务器的结构
服务器需要同时和多个客户端通信
,
但一个程序来处理多个客户端的
请求是很难的
,
因为服务器必须把握每一个客户端的操作状态
。因此一般的做法是,
每有一个客户端连接进来,就启动一个新的服务器程序,确保服务器程序和客户端是一对一的状态
。 具体来说,
服务器程序的结构如图
所示
。
首先
,
我们将程序分成两个模块,
即
等待连接模块
(
图
(
a
))
和
负责与客户端通信的模块
(
图(
b
))
。
1)创建等待连接模块
当服务器程序启动并读取配置文件完成初始化操作后
,
就会运行等待连接模块(
a
)。
这个模块会创建套接字
,
然后进入等待连接的暂停状态。
2)等待连接模块运行并接受连接
接下来
,
当客户端连发起连接时
,
这个模块会恢复运行并接受连接
, 然后启动客户端通信模块(
b
),
并移交完成连接的套接字
。
接下来
,
客户端通信模块(
b
)
就会使用已连接的套接字与客户端进行通信。
3)通信结束后
通信结束后
,
每次有新的客户端发起连接
,
都会启动一个新的客户端通信模块
(
b
),因此(
b
)
与客户端是一对一的关系
。
这样,
(
b
)
在工作时就不必考虑其他客户端的连接情况,
只要关心自己对应的客户端就可以了
。
通过这样的方式,
可以降低程序编写的难度
。
服务器操作系统具有多任务
、
多线程
功能,
可以同时运行多个程序
,
服务器程序的设计正是利用了这一功能
。 当然,
这
种方法在每次客户端发起连接时都需要启动新的程序,这个过程比较耗时,响应时间也会相应增加
。
因此
,
还有一种方法是事先启动几个客户端通信模块,当客户端发起连接时,从空闲的模块中挑选一个出来将套接字移交给它来处理。这个模块就退出了。
服务器端的套接字与端口号
刚才我们介绍了服务器程序的大体结构
,
但如果不深入挖掘调用Socket 库的具体过程
,
我们还是无法理解服务器是如何使用套接字来完成通信的。
因此
,
下面就来看一看服务器程序是如何调用
Socket
库的
。
通过数据收发来区分
首先,
我们来看看客户端与服务器的区别
。
从数据收发的角度来看,
区分
“
客户端
”
和
“
服务器
”
这两个固定的角色似乎不是一个好办法。
现在大多数应用都是由客户端去访问服务器
,
但其实应用的形态不止这一种。
为了能够支持各种形态的应用,最好是在数据收发层面不需要区分客户端和服务器,而是能够以左右对称的方式自由发送数据。
TCP
也正是在这样的背景下设计出来的。不过,
这其中还是存在一个无法做到左右对称的部分,那就是连接操作
。
连接这个操作是在有一方等待连接的情况下
,
另一方才能发起连接,如果双方同时发起连接是不行的,
因为在对方没有等待连接的状态下
,
无法单方面进行连接。
因此,只有这个部分必须区分发起连接和等待连接这两个不同的角色
。
从数据收发的角度来看
,
这就是客户端与服务器的区别
,
也就是说,发起连接的一方是客户端,等待连接的一方是服务器。
这个区别体现在如何调用 Socket
库上
。
首先
,
客户端的数据收发需要经过下面 4 个阶段。
客户端
(
1
)
创建套接字
(
创建套接字阶段
)
(
2
)
用管道连接服务器端的套接字
(
连接阶段
)
(
3
)
收发数据
(
收发阶段
)
(
4
)
断开管道并删除套接字
(
断开阶段
)
服务器
相对地
,
服务器是将阶段
(
2
)
改成了等待连接
,
具体如下
。
(
1
)
创建套接字
(
创建套接字阶段
)
(
2-1
)
将套接字设置为等待连接状态
(
等待连接阶段
)
(
2-2
)
接受连接
(
接受连接阶段
)
(
3
)
收发数据
(
收发阶段
)
(
4
)
断开管道并删除套接字
(
断开阶段
)
下面我们像前面介绍客户端时一样
,
用伪代码来表示这个过程
,
如图所示。
首先,
协议栈调用
socket
创建套接字
(
图
(
1
)),
这一步和客户端是相同的
。接下来调用 bind
将端口号写入套接字中
(
图
(
2-1
))。
在客户端发起连接的操作中,
需要指定服务器端的端口号
,
这个端口号也就是在这一步设置的。
具体的编号是根据服务器程序的种类
,
按照规则来确定的
,例如Web
服务器使用
80
号端口。
设置好端口号之后
,
协议栈会调用
listen
向套接字写入等待连接状态这一控制信息(
图
(
2-1
))。
这样一来
,
套接字就会开始等待来自客户端
的连接网络包。然后,
协议栈会调用
accept
来接受连接
(
图
(
2-2
))。
由于等待连接的模块在服务器程序启动时就已经在运行了,
所以在刚启动时
,
应该还没有客户端的连接包到达。
可是,包都没来就调用 accept 接受连接,可能大家会感到有点奇怪,不过没关系,因为如果包没有到达,就会转为等待包到达的状态,并在包到达的时候继续执行接受连接操作
。
因此
,
在执行accept 的时候
,
一般来说服务器端都是处于等待包到达的状态
,
这时应用程序会暂停运行。
在这个状态下
,
一旦客户端的包到达
,
就会返回响应包并开始接受连接操作。
接下来
,
协议栈会给等待连接的套接字复制一个副本,
然后将连接对象等控制信息写入新的套接字中
。
刚才我们介绍了调用 accept
时的工作过程
,
到这里
,
我们就创建了一个新的套接字
, 并和客户端套接字连接在一起了。
当
accept
结束之后
,
等待连接的过程也就结束了
,
这时等待连接模块会启动客户端通信模块,
然后将连接好的新套接字转交给客户端通信模块
, 由这个模块来负责执行与客户端之间的通信操作。
之后的数据收发操作和
刚才说的一样
,
与客户端的工作过程是相同的。
其实在这一系列操作中,
还有一部分没有讲到
,
那就是在复制出一个新的套接字之后,
原来那个处于等待连接状态的套接字会怎么样呢
?
等待连接的套接字仅作为生产新副本的模板:
其实它还会以等待连接的状态继续存在
,
当再次调用
accept
,
客户端连接包到达时,
它又可以再次执行接受连接操作
。
接受新的连接之后
,
和刚才一样
,
协议栈会为这个等待连接的套接字复制一个新的副本,然后让客户端连接到这个新的副本套接字上。
像这样每次为新的连接创建新的套接字就是这一步操作的一个关键点。
如果不创建新副本
,
而是直接让客户端连接到等待连接的套接字上,
那么就没有套接字在等待连接了
,
这时如果有其他客户端发起连接就会遇到问题。
为了避免出现这样的情况
,
协议栈采用了这种创建套接字的新副本,
并让客户端连接到这个新副本上的方法
。
新副本与模板套接字同端口号
此外,创建新套接字时端口号也是一个关键点
。
端口号是用来识别套接字的,
因此我们以前说不同的套接字应该对应不同的端口号
,
但如果这样做,
这里就会出现问题
。
因为在接受连接的时候
,
新创建的套接字副本就必须和原来的等待连接的套接字具有不同的端口号才行。
这样一来
,
比如客户端本来想要连接 80
端口上的套接字
,
结果从另一个端口号返回了包,
这样一来客户端就无法判断这个包到底是要连接的那个对象返回的
, 还是其他程序返回的。
因此,新创建的套接字副本必须和原来的等待连接的套接字具有相同的端口号。
但是这样一来又会引发另一个问题。
端口号是用来识别套接字的
,
如果一个端口号对应多个套接字,
就无法通过端口号来定位到某一个套接字了。
当客户端的包到达时
,
如果协议栈只看
TCP
头部中的接收方端口号
,是无法判断这个包到底应该交给哪个套接字的。 这个问题可以用下面的方法来解决
,即要确定某个套接字时,不仅使用服务器端套接字对应的端口号,还同时使用客户端的端口号再加上 IP 地 址
,
总共使用下面
4
种信息来进行判断
。
服务器上可能存在多个端口号相同的套接字,但客户端的套接字都是对应不同端口号的,因此我们可以通过客户端的端口号来确定服务器上的某个套接字
。
不过
,
使用不同端口号的规则仅限一台客户端的内部
,
当有多个客户端进行连接时,
它们之间的端口号是可以重复的
。
因此
,
我们还必须加上客户端的 IP
地址才能进行判断
。
描述符和端口号的区别
例如
,
IP
地址为
198.18.203.154的客户端的 1025
端口
,
就和
IP
地址为
198.18.142.86
的客户端的
1025
端
口对应不同的套接字
。
如果能够理解上面这些内容
,那么关于套接字和端口号的知识就已经掌握得差不多了。说句题外话,
既然通过客户端
IP
地址
、
客户端端口号
、
服务器
IP
地址、
服务器端口号这
4
种信息可以确定某个套接字
,
那么要指代某个套接字时用这 4
种信息就好了
,
为什么还要使用描述符呢
?
这个问题很好
,
不过我们无法用上面 4
种信息来代替描述符
。
原因是
,
在套接字刚刚创建好
,
还没有建立连接的状态下,这 4 种信息是不全的。此外,为了指代一个套接字,使用一种信息(描述符)比使用 4 种信息要简单。
出于上面两个原因,
应用程序和协议栈之间是使用描述符来指代套接字的
。
服务器的接收操作
1)电信号转数字信号
现在, 客户端发送的网络包已经到达了服务器。到达服务器的网络包其本质是电信号或者光信号,接收信号的过程和客户端是一样的。接收操作的第一步是网卡接收到信号,然后将其还原成数字信息 。局域网中传输的网络包信号是由 1 和 0 组成的数字信息与用来同步的时钟信号叠加而成的,因此只要从中分离出时钟信号,然后根据时钟信号进行同步,就可以读取并还原出 1 和 0 的数字信息了。 信号的格式随传输速率的不同而不同,因此某些操作过程可能存在细微差异,例如 10BASE-T 的工作方式如图所示。首先从报头部分提取出时钟信号(图①),报头的信号是按一定频率变化的,只要测定这个变化的频率就可以和时钟信号同步了。接下来,按照相同的周期延长时钟信号(图 ②),并在每个时钟周期位置检测信号的变化方向(图 ③)。图中用向上和向下的箭头表示变化方向,实际的信号则是正或负的电压,这里需要检测电压是从正变为负,还是从负变为正,这两种变化方向分别对应 0 和 1 (图 ④)。在图中,向上的箭头为 1,向下的箭头为 0,实际上是从负到正变化为 1,从正到负变化为 0。这样,信号就被还原成数字信息了。
2)FCS校验
接下来需要根据包末尾的帧校验序列(FCS)来校验错误
,
即根据校验公式
计算刚刚接收到的数字信息
,
然后与包末尾的
FCS
值进行比较
。 FCS 值是在发送时根据转换成电信号之前的数字信息进行计算得到的
, 因此如果根据信号还原出的数字信息与发送前的信息一致,
则计算出的 FCS 也应该与包末尾的
FCS
一致
。
如果两者不一致
,
则可能是因为噪声等影响导致信号失真,
数据产生了错误
,
这时接收的包是无效的
,
因此需要丢弃
。
3)检查MAC头部
当 FCS
一致
,
即确认数据没有错误时
,
接下来需要检查 MAC 头部中的接收方 MAC 地址,看看这个包是不是发给自己的
。
以太网的基本工作方式是将数据广播到整个网络上,
只有指定的接收者才接收数据
,
因此网络中还有很多发给其他设备的数据在传输,
如果包的接收者不是自己
,
那么就需要丢弃这个包。
4)中断:提醒CPU
到这里,
接收信号并还原成数字信息的操作就完成了
,
还原后的数字信息被保存在网卡内部的缓冲区中。
上面这些操作都是由网卡的
MAC
模
块
来完成的。在这个过程中,
服务器的
CPU
并不是一直在监控网络包的到达
,
而是在执行其他的任务,
因此
CPU
并不知道此时网络包已经到达了
。
但接下来的接收操作需要 CPU
来参与
,
因此网卡需要通过中断将网络包到达的事件通知给 CPU
。接下来,
CPU
就会暂停当前的工作
,
并切换到网卡的任务
。
5)运行网卡驱动,确定协议类型
然后
,
网卡驱动会开始运行,
从网卡缓冲区中将接收到的包读取出来
,
根据 MAC头部的以太类型字段判断协议的种类,并调用负责处理该协议的软件
。
这里,
以太类型的值应该是表示
IP
协议
,
因此会调用
TCP/IP
协议栈
,
并将包转交给它
。
6)调用IP模块
当网络包转交到协议栈时
,
IP
模块会首先开始工作
,
检查
IP
头部
。
IP模块首先会检查 IP 头部的格式是否符合规范,然后检查接收方 IP 地址, 看包是不是发给自己的
。
当服务器启用类似路由器的包转发功能时
,
对于
不是发给自己的包
,
会像路由器一样根据路由表对包进行转发
。
确认包是发给自己的之后,接下来需要检查包有没有被分片
。
检查
IP头部的内容就可以知道是否分片
,
如果是分片的包
,
则将包暂时存放在内存中,
等所有分片全部到达之后将分片组装起来还原成原始包
;
如果没有分片,
则直接保留接收时的样子
,
不需要进行重组
。
到这里
,
我们就完成了包的接收。
接下来需要检查 IP 头部的协议号字段,并将包转交给相应的模块
。
例如,
如果协议号为
06
(
十六进制
),
则将包转交给
TCP
模块
;
如果是
11
(
十六进制),
则转交给
UDP
模块
。
这里我们假设这个包被交给
TCP
模块处理,
然后继续往下看。
7)调用TCP模块:TCP处理连接包
前面的步骤对于任何包都是一样的
,
但后面的 TCP 模块的操作则根据包的内容有所区别
。
首先
,
我们来看一下发起连接的包是如何处理的
。 当 TCP
头部中的控制位
SYN
为
1
时
,
表示这是一个发起连接的包(图
①
)。
这时
,
TCP
模块会执行接受连接的操作
,
不过在此之前,需要先检查包的接收方端口号,并确认在该端口上有没有与接收方端口号相同且正在处于等待连接状态的套接字
。
如果指定端口号没有等待连接的套接字,
则向客户端返回错误通知的包
。如果存在等待连接的套接字,
则为这个套接字复制一个新的副本
,
并
将发送方
IP
地址
、
端口号
、序号初始值、窗口大小等必要的参数写入这个套接字中,
同时分配用于发送缓冲区和接收缓冲区的内存空间
。
然后生成代表接收确认的 ACK
号
,
用于从服务器向客户端发送数据的序号初始值
, 表示接收缓冲区剩余容量的窗口大小,
并用这些信息生成 TCP 头部,委托IP模块发送给客户端
。这个包到达客户端之后,
客户端会返回表示接收确认的
ACK
号
,
当这个 ACK
号返回服务器后
,
连接操作就完成了
。这时,
服务器端的程序应该进入调用
accept
的暂停状态
,
当将新套接字的描述符转交给服务器程序之后,
服务器程序就会恢复运行
。
如果收到的是发起连接的包,则 TCP 模块会:
(1) 确认 TCP 头部的控制位 SYN;
(2) 检查接收方端口号;
(3) 为相应的等待连 接套接字复制一个新的副本;
(4) 记录发送方 IP 地址和端口号等信息。
8)调用TCP模块:TCP处理数据包
接下来我们来看看进入数据收发阶段之后
,
当数据包
到达时
TCP
模块是如何处理的(
图
②
)。
首先,
TCP
模块会检查收到的包对应哪一个套接字
。
在服务器端
,
可能有多个已连接的套接字对应同一个端口号,
因此仅根据接收方端口号无法找到特定的套接字。
这时我们需要根据 IP 头部中的发送方 IP 地址和接收方 IP 地址,以及 TCP 头部中的接收方端口号和发送方端口号共 4 种信息,找到上述 4 种信息全部匹配的套接字
。
找到 4
种信息全部匹配的套接字之后
,
TCP
模块会对比该套接字中保存的数据收发状态和收到的包的 TCP
头部中的信息是否匹配
,
以确定数据收发操作是否正常。
具体来说,就是根据套接字中保存的上一个序号和数据长度计算下一个序号,并检查与收到的包的 TCP 头部中的序号是否一致
。
如果两者一致
,
就说明包正常到达了服务器
,
没有丢失
。
这时
,
TCP模块会从包中提出数据,
并存放到接收缓冲区中
,
与上次收到的数据块连接起来。
这样一来
,
数据就被还原成分包之前的状态了
。
当收到的数据进入接收缓冲区后,TCP 模块就会生成确认应答的 TCP头部
,
并根据接收包的序号和数据长度计算出 ACK 号
,
然后委托
IP
模块
发送给客户端
。 收到的数据块进入接收缓冲区,
意味着数据包接收的操作告一段落了
。
接下来,
应用程序会调用
Socket
库的
read
(
图
③
)
来获取收到的数据
, 这时数据会被转交给应用程序。
如
果应用程序不来获取数据,则数据会被一直保存在缓冲区中
,
但一般来说
,
应用程序会在数据到达之前调用
read等待数据到达,
在这种情况下
,
TCP
模块在完成接收操作的同时
,
就会执行将数据转交给应用程序的操作。然后,
控制流程会转移到服务器程序
,
对收到的数据进行处理
,
也就是检查 HTTP
请求消息的内容
,
并根据请求的内容向浏览器返回相应的数据。
收到数据包时,TCP 模块会:
(1) 根据收到的包的发送方 IP 地址、发送方端口号、接收方 IP 地址、接收方端口号找到相对应的套接字;
(2) 将数据块拼合起来并保存在接收缓冲区中;
(3) 向客户端返回 ACK。
9)调用TCP模块:断开连接操作
当数据收发完成后
,
便开始执行断开操作
。
这个过程和客户端是一样的,
让我们简单复习一下
。在 TCP
协议的规则中
,
断开操作可以由客户端或服务器任何一方发起,
具体的顺序是由应用层协议决定的
。
Web
中
,
这一顺序随
HTTP
协议版本不同而不同,
在
HTTP1.0
中
,
是服务器先发起断开操作
。这时,
服务器程序会调用
Socket
库的
close
,
TCP
模块会生成一个控制位 FIN
为
1
的
TCP
头部
,
并委托
IP
模块发送给客户端
。
当客户端收到在返回 ACK 号之前,会先等待一段时间,看看能不能和后续的应答包合并。
这个包之后
,
会返回一个
ACK
号
。
接下来客户端调用
close
,
生成一个
FIN
为
1
的
TCP
头部发给服务器
,
服务器再返回
ACK
号
,
这时断开操作
就完成了
。
HTTP1.1
中
,
是客户端先发起断开操作
,
这种情况下只要将客
户端和服务器的操作颠倒一下就可以了
。
无论哪种情况,当断开操作完成后,套接字会在经过一段时间后被删除。