计网实验:Socket 编程
1. 实验目的
- 掌握 TCP 和 UDP 协议主要特点和工作原理
- 理解 socket 的基本概念和工作原理
- 编程实现 socket 网络通信(C/Python/Java)
2. 实验代码实现
2.1 代码组织结构
本次实验实现的 3 个项目(6 个程序)的代码分别为:
server_reg.c
和client_reg.c
:简单的网络注册服务器和客户端server_check.c
和client_check.c
:简易的网络签到服务器和客户端server_chat.c
和client_chat.c
:UDP 聊天室服务器和客户端
其中这些代码需要依赖以下的自己写的函数库:
csutil.c
:Client & Server Utility Function,客户端和服务器间通信所需的函数,封装了很多对 socket 的操作stuutil.c
:Student Utility Function,建立、查询、写入学生数据库所需函数的封装chatutil.c
:Chatting Utility Function,基于 UDP 的自定义聊天应用层协议的实现
也包括了这三个函数库的头文件:csutil.h
、stuutil.h
和chatutil.h
。
为了方便一起编译和链接数量众多的代码文件,同时写了 Makefile。其中本次实验的 6 个程序分别编译输出为server_reg
、client_reg
、server_check
、client_check
、server_chat
、client_chat
。
2.2 csutil.c
中的 socket 操作封装
由于本次实验都围绕 socket 编程展开,因此可以把常规操作写进csutil.c
一些函数里。这样之后有重复的功能使用时,只需添加#include "csutil.h"
,即可复用这些代码。
2.2.1 异常处理函数
使用 socket 时的很多函数都可能会产生错误。由 UNIX 程序设计可知,当使用 UNIX 提供的函数出现异常时,异常代码会保存在extern
的全局变量errno
中,需要包含errno.h
头文件。而要获取该错误代码对应的字符串信息,可使用perror
函数,需要包含string.h
头文件。
这里由于程序逻辑简单,因此一旦遇到错误,则打印是哪个函数出错,以及错误信息,并退出程序,返回值为 1:
1 2 3 4 5 6 7 |
|
2.2.2 IP:port
和sockaddr
结构体的转换函数
在我们使用 UNIX 的bind
、connect
和sendto
函数时,需要传入sockaddr
结构体。而通常我们只是手动输入 IP 和端口号。因此编写了get_addr
函数进行 IP+端口号到sockaddr_in
结构体的转换:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
|
同时,在使用 UNIX 的accept
和recvfrom
函数时,我们需要获取是哪个 IP 的哪个端口上的客户端向我们发来数据。因此也需要将从这两个函数中获得的sockaddr
结构体转换为 IP+端口号:
1 2 3 4 5 6 7 8 9 10 |
|
这里的转换函数没有使用实验 PPT 上提供的
inet_addr
、inet_ntoa
等函数,因为在《UNIX 环境高级编程》一书的第 16 章中提到,这两个函数没有这里使用的inet_pton
、inet_ntop
有更好的兼容性这里的转换都只是考虑 IPv4 地址字符串,且不支持输入域名再解析 IP 地址。如果是 IPv6 地址,应将
sockaddr_in
结构体换成sockaddr_in6
,且将AF_INET
换成AF_INET6
。
2.2.2 服务器的建立函数
2.2.2.1 函数原型
由于建立服务器的代码在本实验中也经常复用,因此将建立服务器的过程封装成make_server
函数。函数原型如下:
1 2 3 4 5 6 7 |
|
-
sip
和sport
:该函数在sip:sport
上运行服务器。下文中出现的变量名,前缀带有s
的意为服务端的信息,前缀带有c
的意为客户端的信息 -
protocol
:服务器的传输层协议由protocol
指定,可以是TCP
或UDP
。这两个宏常量在csutil.h
里分别被定义为SOCK_STREAM
和SOCK_DGRAM
-
handler
:服务器遇到用户连接时的处理函数。该函数需要接受一个参数,为用于该连接的(套接字)文件描述符fd
,也即accept
函数的返回值(对于 TCP 协议)或socket
函数的返回值(对于 UDP 协议) -
block
:服务器是否在处理用户连接的过程中阻塞。即服务器能否多进程同时处理并发请求
block
参数只适用于 TCP 的情况。对于 UDP 聊天室而言,由于 UDP 是无连接的,因此不会发生和客户端连接时的阻塞情况,该参数可以任意设置
该函数具体实现如下。首先,进行固定的socket
、bind
操作:
1 2 3 4 5 6 7 8 9 10 11 |
|
这里用于存储客户端 IP 地址的cip
数组长度设置为了 IPv4 地址的最大长度INET_ADDRSTRLEN
,即 16(包括最后的\0
)。这一常量在arpa/inet.h
头文件里定义。
2.2.2.2 迭代 TCP 服务器
之后,针对不同的选项进行不同的操作。首先是迭代的 TCP 服务器:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
|
需要先listen
,之后只需一个循环处理一次accept
即可。并在accept
之后友好的打印客户端的地址信息,再调用handler
函数实现自定义的套接字读写操作与客户端通信。
这里的
listen
的第二个参数并没有像实验 PPT 那样设置为 0。根据 UNIX 的文档,TCP 的最大连接数在sys/socket.h
头文件中有SOMAXCONN
的宏定义。在本地的 Linux 系统上为 128
2.2.2.3 并发 TCP 服务器
其次是并发的 TCP 服务器:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
|
与迭代 TCP 服务器不同的是,程序在accept
之后,立即调用fork
创建子进程,并在子进程中处理这一新建的连接,而父进程继续循环等待其他的连接。
注意到一些细节问题。在 UNIX 系统中,如果在子进程结束之前,父进程没有来得及调用sys.wait.h
中的一系列函数获取子进程的结束信息,则子进程会变成僵尸进程,占用系统资源。但是我们这里不可以在父进程中的while
循环中使用wait
,因为一旦在循环中wait
后,我们的父进程就也被阻塞了,成为了迭代式的 TCP 服务器。因此有两种避免的方法:
- 一种是
fork
两次,在子进程的子进程中处理请求,并提前结束子进程,使用wait
获取子进程信息。这样子进程的子进程就会被init
进程接管,避免了僵尸进程的产生 - 另外是通过信号处理函数。当子进程终止时,父进程会收到
SIGCHLD
信号。因此如果我们在收到该信号时进行wait
,或者直接忽略该信号,让子进程的信息直接从系统的进程表项中删除,都可以避免僵尸进程的产生。这里使用和实验 PPT 上一样的忽略该信号的做法:在父进程中调用signal(SIGCHLD, SIG_IGN)
,需要包含signal.h
头文件
同时,为了进一步节约资源,当一个进程fork
时,其文件表项也会被复制一份,就像调用了dup
函数一样。但是这里在子进程中,我们不需要父进程的sfd
(套接字文件描述符),在父进程中我们不需要子进程的cfd
(针对一个 TCP 连接的文件描述符),因此可以在各自的分支中关闭。
2.2.2.4 UDP 服务器
由于 UDP 是无连接的,因此其实现最简单,既不要listen
也不要accept
,直接循环调用handler
进行数据收发即可:
1 2 3 4 5 6 |
|
2.2.3 客户发起与服务器的通信函数
客户端与服务器发起通信也是一个在本实验中经常复用的函数。这里和建立服务器类似,函数需要提供服务器 IP、服务器端口号、运输层协议、以及handler
函数这几个参数。之后函数通过connect
与服务器建立连接,获取到用于该连接的文件描述符cfd
,之后调用handler
,传入cfd
参数进行数据的收发处理。具体实现如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
|
注意到这里的connect
并没有排除 UDP 协议!这是因为根据《UNIX 环境高级编程》第 16 章 16.4 节,UDP 协议下也可调用connect
函数。这样做的效果是每次使用send
发送数据时,会默认向connect
函数指定的地址发送。且调用recv
函数时,也只会从connect
函数指定的地址接收数据报。不需要我们每次使用sendto
和recvfrom
函数指明地址。
2.2.4 数据收发函数
由于 UNIX 提供的收发函数仍然较为底层,且没有异常处理,因此针对recv
、send
、recvfrom
、sendto
这几个函数编写了更友好的封装:recv_data
、send_data
、recv_data_from
、send_data_to
,并添加了异常处理。以recv_data_from
为例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
|
可见其不需要传入sockaddr
结构体,而直接传入接受 IP 和端口号的地址即可。并且不需要指定缓冲区长度,因为在csutil.h
里,统一将发送方和接收方的缓冲区的长度设置为MAXLINE
,8192。也不需要每次在接收前都手动对缓冲区进行 0 初始化,因为函数中已经帮我们通过bzero
进行 0 填充。
注意到该函数中使用了goto
,这里是避免recv
系列函数在执行的过程中被打断。因为后续的程序中有用到信号处理函数,当程序接收到信号时,对于普通的系统调用,会让它执行完再执行信号处理函数,但是对于慢的系统调用,如网络套接字上的读写,当收到信号时,该系统调用会被中断,并返回错误码EINTR
。因此,如果这个时候直接报错退出,是无法实现程序所需功能的。解决方法是,一旦遇到这个错误,就重新进行一遍当前的系统调用,而最简便的解决方法就是利用goto
。
这也是《UNIX 环境高级编程》一书在第 10 章中的示例做法
同样的,以send_data
函数为例:
1 2 3 4 5 6 7 8 9 10 11 |
|
可见其提供了一个选项,如果将长度设置为 0,则不需要我们手动计算需要发送数据的长度。这对发送字符串常量时非常友好。
2.3 简单的网络注册服务(迭代)
2.3.1 stuutil.c
学生数据库
为了实现学生的信息存储、添加、查询,因此编写了stuutil.c
。这里采用文本文件形式存储学生信息:第一行为学生姓名,第二行为学生的学号。每个学生信息之间也用一行隔开。文本文件的位置为stuutil.h
里定义的DB_PATH
,即服务端程序运行目录下的student.txt
。
这里需要用到add_student
函数,向数据库中添加学生信息,即直接在该文本文件后追加信息:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
|
2.3.2 服务端程序
有了stuutil.h
和之前的csutil.h
后,接下来的写代码过程就异常轻松。因为我们只要从命令行中获取 IP 和端口,指定协议后,自行编写handler
函数即可。例如,服务端的完整代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 |
|
其中,在make_server
函数中,设置协议为 TCP,并设置为迭代模式。而在处理客户端请求的handler
函数中,每次循环进行两轮收发,分别对应姓名和学号。直到输入的姓名为bye
时退出。同时,针对接收到的姓名和学号,在服务端的屏幕上打印,并接着通过 2.3.1 中提到的add_student
函数存储。
2.3.3 客户端程序
而对于客户端,其结构大致类似。只需将main
函数的make_server
换成 2.2.3 中提到的contact_server
,并且实现处理和服务器连接handler
即可。客户端的handler
函数实现如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
|
同样进行两轮的收发。并在输入为bye
的情况下退出。
2.4 简易的网络签到服务(并发)
2.4.1 stuutil.c
学生数据库
和 2.3.1 节同样,在stuutil.c
中实现简单的学生查询函数has_student
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
|
由于签到只考虑姓名,因此如果学生姓名存在,则返回true
。
2.4.2 服务端程序
服务端程序的主函数只需将make_server
中的最后一个参数由 2.3.2 中的true
改为false
,即建立并发式的 TCP 服务器:
1 |
|
服务端程序的handler
函数如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
|
每次循环只进行一轮收发。如果收到的学生姓名存在,则也同时输出在服务端的屏幕上(方便老师后台查看签到情况)。如果不存在也会向客户端发送错误信息。如果接收到bye
,则退出。
2.4.3 客户端程序
客户端的handler
更为简单,每次循环进行一轮收发,如果输入bye
则退出:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
|
2.5 基于 UDP socket 的聊天室
2.5.1 chatutil.c
应用层协议
2.5.1.1 协议报文格式
为了在 UDP 协议的基础上实现聊天室用户注册、客户端向服务器的消息传送、服务端对消息的广播,因此在chatutil.h
里定义了自己设计的应用层协议(后文暂且称为简单聊天协议)的报文格式。头部部分结构体为struct heaer
:
1 2 3 4 5 6 7 8 9 |
|
采用#pragma pack(1)
强制一字节对齐。其对应如下:
0-7bit | 8-15bit |
---|---|
客户端 ID 高 8 位 | 客户端 ID 低 8 位 |
标志位 | 数据部分长度 |
头部之后紧接着就是数据部分
-
客户端 ID:每个连接到 UDP 聊天室服务器的客户端都有一个唯一的客户端 ID,用于标识客户端的身份。如果一个客户端刚进入聊天室,但还没有注册,则使用默认 ID,数值为 0。由于 ID 有 16 位,因此本 UDP 服务器最大可支持 65535 个客户端同时聊天
-
标志位:用于标志该报文的作用。具体 8 个 bit 由高位到低位的分布为:
0x80 | 0x40 | 0x20 | 0x10 | 0x08 | 0x04 | 0x02 | 0x01 |
---|---|---|---|---|---|---|---|
保留 | 保留 | BRD | FIN | SND | REG | NAK | ACK |
- ACK:服务器同意注册的确认报文,以及对客户端发送的消息应答的确认报文
- NAK:服务器不同意注册的否定报文(名称重复、当前在线客户端数已满)
- REG:客户端的注册请求报文
- SND:客户端向服务器发送的聊天消息报文
- FIN:客户端退出聊天室报文
-
BRD:服务器向客户端广播聊天消息及其他通知信息报文
-
数据部分长度:按字节计算的数据部分的长度
2.5.1.2 协议确认机制
为了确保客户端能及时检测到服务器挂了,而不是一直接收,不知道是没有人发送消息还是服务器挂了,因此针对客户端向服务器的通信引入了确认机制:
- 服务器会对客户端发送的 REG 报文进行 REG ACK 或 REG NAK 确认
- 服务器会对客户端发送的 SND 报文进行 SND ACK 确认
- 服务器会对客户端发送的 FIN 报文进行 FIN ACK 确认
- 如果一段时间内客户端没有收到确认,则认为服务器已经挂了。超时时间在
chatutil.h
里定义为TIMEOUT
,为 8 秒
2.5.1.2 应用层的收发函数
为了处理自己设计的应用层协议,还需设计针对该协议的收发函数。而服务器的收发和客户端的收发又有所不同,因为 UDP 的无连接性质,服务器需要知道另一方的地址,即要使用sendto
和recvfrom
。以服务器的发送函数举例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
|
需要知道要发送的客户端的 ID、IP 地址、端口号,以及需要指明的标志。之后该函数通过这些信息先构建简单聊天协议的头部,再将头部和数据部分拼接起来,复制到buf
中,再使用csutil.c
中的send_data_to
函数发送。
再以客户端的接收函数举例。由于在 2.2.3 中,客户端事先已经通过connect
函数指明服务器的地址,因此客户端不需要在收发函数中再指明服务器的地址:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
|
同样,客户端收到数据后,先存放到buf
中,之后依次复制给header
头部结构体和data
数据部分。再将header
中的flags
字段给flags
指针指向的变量,并返回header
中的客户端 ID。
为了考虑服务器突然掉线的情况,本函数接收timeout
参数,单位秒。通过setsockopt
函数设置了recv
系列函数的超时时间。如果超时,则recv
会产生 Resource not avaliable 的错误信息,并推出程序。
2.5.2 服务端程序
2.5.2.1 主函数部分
与 2.3.2 和 2.4.2 的服务端程序类似,主函数中只需将make_server
的协议参数改为 UDP 即可:
1 |
|
2.5.2.2 客户端信息存储
定义client
结构体存放单个用户信息,包括 IP、端口号、名称,以及是否存活:
1 2 3 4 5 6 7 8 |
|
这里引入是否存活字段alive
的机理是,初始化时,所有的alive
都为false
。当客户端连接时,该客户端 ID 对应的alive
变为true
。当客户端向服务器发送 FIN 时,服务器就将该客户端 ID 对应的alive
设为false
。待新用户加入时,如果发现有 ID 对应的内容为false
,即可将该 ID 分配给新的用户,直接覆盖原有数据。这省去了数组元素的添加、删除操作。
之后,建立clis
数组,数组下标即为客户端 ID。这样只需通过 ID 一次寻址即可获得客户信息,而不用写个循环进行查找:
1 |
|
为了在用户注册时查找是否有名称重复,因此编写了has_name
函数:
1 2 3 4 5 6 7 8 9 10 |
|
在同意用户注册后,需要给该用户分配一个 ID,并且在该 ID 对应的结构体中存放用户信息。因此编写了cli_alloc
函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
|
从下标 1 开始检测到第一个alive
字段为false
的客户端,分配给新的客户端使用。如果客户端已满,则返回 0。
2.5.2.3 应用层报文处理
在应用层的handler
函数中,首先调用chatutil.c
中的server_recv_chat
接受一个客户端发来的一个简单聊天协议的应用层报文,并打印该报文信息:
1 2 3 4 5 6 |
|
之后,根据该报文的标志位不同,做不同的处理。首先是 REG 标志位,处理用户注册的情况:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
|
当用户注册成功时,服务器会像所有的客户端(包括注册的客户端)广播该客户加入聊天的信息。
接下来考虑 SND 标志位:
1 2 3 4 5 6 7 8 9 |
|
服务器首先向发送方回复 ACK,然后将从客户端收到的聊天信息转发给其他的客户端。
最后考虑 FIN 标志位:
1 2 3 4 5 6 7 8 9 10 |
|
服务器也是先回复 ACK,然后将该客户都标志位置为false
,最后向其他成员广播该成员离开的信息。
2.5.3 客户端程序
2.5.3.1 主函数部分
因为普通的程序输入和输出只能依次进行,就像之前的注册和签到服务一样,各自进行一轮收发。但是聊天室的用户应该想发就发。因此主函数部分首先注册 Ctrl+C 信号的处理函数switch_mode
,用于客户端可以在按下 Ctrl+C 后立即切换到输入模式。
而连接服务器部分只需稍微修改contact_server
的参数变成 UDP 即可:
1 2 3 |
|
之后的注册和接受消息环节靠handler
实现,而发送消息环节靠信号处理实现。
2.5.3.2 注册和接收消息环节
首先是注册环节:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
|
可见,在注册环节中也引入了超时机制。如果超时未收到 ACK 或 NAK 回复,则认为服务器挂了,退出。
接收消息环节很简单,只需一直循环接收并打印即可:
1 2 3 4 5 6 7 |
|
注意这里的超时时限被设置为 0,即无限长,因为可能存在大家都潜水一直不说话的情况。因此若要检测服务器是否正常运作,需要靠接下来的发送消息环节的确认实现。
2.5.3.4 发送消息环节
以下是switch_mode
的部分代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
|
可以看到,按下 Ctrl+C 后,首先屏幕上会打印出自己的名字提示输入,之后按下回车,客户端就将该输入信息发送给服务端,并启动超时等待服务端 ACK 报文。如果超时未收到该报文,则认为服务器挂了,结束。如果没有先收到 ACK,而是收到 BRD 广播消息,则也正常打印该消息。
3. 代码运行和探究
使用make
命令,借助之前写的 Makefile 进行自动化编译,之后输入./程序名 ip 端口
即可运行客户端或服务器。
3.1 简单的网络注册服务(迭代)
3.1.1 常规运行效果
打开 1 个服务器(右中),4 个客户端(左边和右上角),以及一个查看netstat
命令的终端。效果如下。
步骤一:开启服务器,运行在本地 6666 端口。然后按照左上、左中、左下、右上的顺序开启客户端。之后可见左上的服务端优先和服务器通信,其他的则进行等待。由netstat
可知,所有的连接均处于ESTABLISHED
状态,即三次握手完成。并且由服务端输出的信息可知,当前是端口号 36656 的进程在和服务器通信。如下图所示:
步骤二: 在左上输入bye
,之后左中的客户端立即和服务器开始通信。同时由netstat
,原来 6666 到 36656 的连接关闭,而 36656 到 6666 端口的连接处于关闭前的TIME_WAIT
状态。这是因为如课本 5-29 图所示,服务器收到客户的 FIN 和 FIN ACK 后,就关闭了连接,而客户端要在收到服务器的 FIN ACK 后,等待 2MSL 再关闭连接。且由服务器输出信息可知,当前客户端端口号为 36658。如下图所示:
步骤三:在左中输入bye
,左下角立即开始连接。可见其是按发起连接的先后顺序来的。此时的netstat
中 56658 到 6666 端口也处于TIME_WAIT
状态,而右上角的可怜的客户端仍在等待中。如下图所示:
步骤四:在左下输入bye
,右上和服务器通信,输入完后再输入bye
。等待一段时间后(让这段时间超过 2MSL),再使用netstat
查看,则以上的连接全部释放。如下图所示:
可见其表现符合预期。
上文提到,这里的 TCP 连接的
backlog
设置为了SOMAXCONN
,在本地系统上是 128,因此这几个客户端的连接均被接受,且一直在等待中
3.1.2 TCP 连接数限制情况
之前在csutil.c
的make_server
函数中,将 TCP 连接数(即listen
的backlog
参数)设置为SOMAXCONN
,为 128,既不是 0 也不是 1。若将其改为 0,重复上述步骤,如下图所示:
可以发现,右下角用netstat
的时候,端口号最高的那个客户端 36690(左下)、36692(右上)对 6666 的连接为SYN_SENT
状态,只完成一次握手。并且不久之后,这两个就出现了 Connection timed out 的错误,可见他们并没有成功和服务器建立连接。这是因为由课本图 5-28 所示,TCP 建立连接前要进行三次握手。而由于 TCP 最大连接数的限制,后两个客户端发送 SYN 后,服务端没有响应 SYN ACK,导致只完成一次握手,并且因为握手超时而结束链接
现在将其改为 1,重复上述步骤,如下图所示:
可见这次多了一个客户端的连接容量。据此推测最大可连接的客户端数可能是backlog + 2
。
3.1.3 客户机绑定固定端口情况
为此,在csutil.c
的contact_server
函数中插入以下代码,将客户端绑定在本地的 5555 端口:
1 2 3 4 |
|
之后,使用make
重新编译,先让客户端给服务器通信,然后关闭,再给服务器通信。第一次通信时一切正常,但如果结束后,立即通信,客户端程序会被提示 Address already in use,说明之前绑定的 5555 端口号已被占据,如下图所示:
可以看到,之前建立的连接仍处于TIME_WAIT
状态。因此如果在连接完全释放之前再用同样的端口绑定,则会因为之前的端口号还在使用中而无法绑定。但是如果等待一段时间,经过 2MSL 后,再发起连接,则又可以通信:
因此,不建议客户端绑定同样的端口号,是因为客户端关闭连接后,连接需要等待一段时间完全释放,因此端口号不能立即复用,使得在一段时间内无法建立连接。
3.1.4 IP 地址和端口的字节序转换
由于inet_ntop
和inet_pton
两个函数自带网络字节序转换。为此,只能演示端口的网络字节序不转换的情况。将csutil.c
中的get_addr
函数中的
1 |
|
改为
1 |
|
使用make
编译后,仍然在 6666 端口运行服务器:
可以看到,仍然能够正常通信,这是因为客户端和服务器都使用到get_addr
函数,因此名为 6666 端口,实际均当作了 2586 端口。但是在实际应用上,这种错误是不允许的。
接下来分析一下原因。将 6666 转成 16 位二进制数,分成两个字节为 $$ 6666_{10}=0001\;1010\quad0000\;1010_2 $$ 而将这两个字节反序,而字节内的顺序不变,再转回 10 进制,就是 2586: $$ 0000\;1010\quad0001\,1010_2=2586_{10} $$ 可见在网络和在本地机器上使用了不同的端序来存放数据,因此转换是必要的。
3.2 简易的网络签到服务(并发)
3.2.1 常规运行效果
打开 1 个服务器(右下),也是运行在 6666 端口;5 个并发客户端(左边和右上、右中)。效果如下:
图 1:五个客户端均可同时与服务器建立连接,服务器也展示了 5 个客户端连入的信息。
图 2:5 个客户端可以同时发送姓名给服务器签到,服务器打印签到信息。对于不存在的姓名,也能提示错误:
可见其表现符合预期。
3.2.2 父进程中是否关闭cfd
的影响
在上述的代码中,服务器通过socket
获取的描述符为sfd
,而通过accept
获取的面向单个连接的描述符为cfd
。上述代码在父进程中关闭了cfd
,其解释已经在 2.2.2.3 中提到一些。
以下左侧的 6666 端口是父进程关闭cfd
的情况,右侧的 8888 端口是父进程不关闭cfd
的情况,在通信时没有差异,但是当客户端退出时,两侧的netstat
结果不同:
左侧的正常进入了TIME_WAIT
,不久后连接被完全释放。而右侧的服务器处于CLOSE_WAIT
状态,客户端处于FIN_WAIT2
状态,这说明只完成了 FIN 和 ACK 的两次握手。也就是说服务器的应用进程并没有关闭该连接,没有主动发起 FIN ACK。
这可以通过 UNIX 的文件描述符复制来解释。当进程被fork
后,子进程中的文件描述符就像是被dup
函数过了一样,即父进程和子进程的这两个文件描述符都指向一个 file table,其中包含了文件状态,当前偏移量等文件信息。而只有当指向某个 file table 的文件描述符为 0 时,该文件会真正关闭,否则只是对某个进程“关闭”,从进程中的 process table 中移除。因此,如果只在子进程中调用close
,则其虽然在子进程中的 process table 被移除了,但是父进程中并没有,因此该连接的 file table 仍然存在,也就意味着连接并未关闭。因此,需要在父进程中关闭cfd
,否则浪费系统资源,也让客户端不能完全释放连接。
3.3 基于 UDP socket 的聊天室
3.3.1 运行效果
也是在本地的 6666 端口开一个服务器(右下),之后五个客户端进行聊天。默认是接收信息,如果要发送,则需要按 Ctrl+C:
可见,左下角的在注册姓名时,尝试使用重复的姓名,导致注册失败。并且服务器会发送用户加入和退出的提示及其地址。服务器端也会记录日志,保留每一次的客户端发来的简单聊天协议应用层报文。
可以看到,在注册输入姓名时,服务器收到的报文的id
为 0,flags
为 4,代表 REG。而发送消息时,服务器收到各自用户的id
,且flags
为 8,代表 SND。用户退出时,flag
为 16,代表 FIN。
3.3.2 异常捕捉
紧接着上述聊天。如果服务器突然挂了(按 Ctrl+C 结束),则客户端再向其发消息的时候,就会报错。这里因为端口已经关闭,而不是服务器无响应,因此会立即有 Connection refused 错误:
如果我们乱输一个公网的地址,则可能该报文永远不会得到回应,这时候之前引入的超时机制就起作用了。会在超时 8 秒之后报错退出,错误内容是 Resources unavailable:
3.3.3 真实环境:UDP 的不可靠传输
接下来,在自己的服务器 f5soft.site 的 6666 端口(公网地址为 39.97.114.106:6666)部署该服务器,同时在自己的电脑上启动 4 个客户端,连接公网服务器。可以发现,绝大部分功能均正常运行。但是由于 UDP 的不可靠传输,且处于公网环境,其下层也是不可靠的(不像本地网络的下层是可靠的),因此在公网的环境中出现了 UDP 数据报的丢失!
可见 UDP 确实不可靠。如果要彻底解决这一问题,需要在我们的应用层(简单聊天协议)实现可靠传输,因此除了之前的超时机制外,还要引入重传机制等。
4. 实验小结
本次实验,体会并掌握了
- 通过合理的代码复用降低程序的代码量
- UNIX 网络套接字编程基础
- UNIX 多进程并行程序设计
- Socket 网络地址的获取与转换
- TCP、UDP 服务器的建立
- TCP、UDP 客户端与服务器的通信
- TCP 建立连接时的三次握手过程和释放连接时的四次握手过程
- 使用
netstat
命令查看本机的连接和端口状况 - 实现自己的应用层协议——简单聊天协议
- 生产环境和本地环境的区别及 UDP 的不可靠传输验证
5. 相关文件记录
- 代码文件位于
src
文件夹中,其中 server_reg.c
和client_reg.c
:简单的网络注册服务器和客户端server_check.c
和client_check.c
:简易的网络签到服务器和客户端server_chat.c
和client_chat.c
:UDP 聊天室服务器和客户端csutil.c
和csutil.h
:Client & Server Utility Function,客户端和服务器间通信所需的函数,封装了很多对 socket 的操作stuutil.c
和stuutil.h
:Student Utility Function,建立、查询、写入学生数据库所需函数的封装chatutil.c
和chatutil.h
:Chatting Utility Function,基于 UDP 的自定义聊天应用层协议的实现Makefile
:Makefile 自动化编译文件
若要编译运行,在 Linux 环境下,在
src
目录下输入make
,即可生成编译后的文件