XMU《计算机网络与通信》第三次实验报告
一、个人信息
学号:**************
姓名:###
二、实验目的
-
理解TCP和UDP协议主要特点
-
掌握socket的基本概念和工作原理,编程实现socket通信
三、实验任务与结果
任务 1 前置任务
开启两个终端窗口,分别编译、运行
server_example.c
和client_example.c
,观察它们实现的功能。
可以发现,client
发送了一个字符串 Hello Network!
,然后 server
接收了这个字符串,向 client
发送回去,client
又接收了 server
的回复。
任务 1 完善socket客户机
为所有socket函数调用添加错误处理代码;
范例中服务器地址和端口是固定值,请将它们改成允许用户以命令行参数形式输入;
范例中客户机发送的是固定文本“Hello Network!”,请改成允许用户输入字符串,按回车发送;
实现循环,直至客户机输入“bye”退出。
虽然题目只要求修改 client.c
,但是感觉这些要求如果不修改 server.c
很难完成。所以我把 server.c
一起按照要求改了。
任务 1 的代码见 server1.c
和 client1.c
。
错误处理
对于错误处理代码,我的实现方法很经典:将一个系统调用封装为对于的首字母大写的函数,在这个函数中对于返回值进行判断是否出错。比如说,对于 socket
函数,我会定义一个 Socket
函数,在 Socket
函数中,调用 socket
,并判断调用的返回值如果小于零,就输出错误信息并退出,否则将其返回值再返回回去。
例如:
int Socket(int family, int type, int protocol) {
int n;
if ((n = socket(family, type, protocol)) < 0) {
perror("socket error");
exit(1);
}
return n;
}
然后,将 main
函数中,所有用到与网络相关的系统调用的地方,全部改为对应的首字母大写的函数即可。
服务器地址与返回值
对于端口号的处理比较简单,只需要将原来设置端口号的地方,改为将第三个命令参数转成数字的值即可。
server_addr.sin_port = htons(atoi(argv[2]));
对于地址的处理比较难办了。通过对于任务要求中的示例图片可以发现,给定的地址可以是 localhost
这样的域名,而不仅仅是 IP 地址。
所以,为了将域名转为 IP 地址,我使用了 gethostbyname
函数,它接受一个字符串域名作为参数,返回值中的一项就是解析出来的 IP 列表,我这里始终取第一个 IP。如果参数给定的那个字符串本身就是 IP 而不是域名,那么会把这个 IP 地址原封不动地封装返回回来,因此不用判断命令行参数是 IP 地址还是域名。
server_addr.sin_addr = *(struct in_addr*)Gethostbyname(argv[1])->h_addr_list[0];
值得注意的是,gethostbyname
返回得到的 IP 地址本身就是网络格式的了,不需要调用 inet_aton
将字符串转换为网络格式。
连续输入,直到发送 bye
退出
这个很简单,只需要在外面加上一层 while
即可。我的策略是 while
判断来自键盘的输入,只要键盘中还有输入就向服务端发送,然后接受并打印服务端的回应。(不用特殊判断服务端状态,因为如果服务端意外退出了,客户端的 send
网络调用会出错,从而自动退出程序)
结果截图
client:
server:
为了验证程序对于出错的处理,我们在不运行服务端的情况下运行客户端:
或者输入一个不存在的地址/不存在的端口:
设置 backlog
注:根据依照我的观察,在 MacOS 和 Linux 下,两个系统对于 listen
中的 backlog
参数的处理并不相同,这里我将会进行两个系统下处理的对比。
backlog = 0
(MacOS)
只有一个客户端:
只有两个客户端:
只有三个客户端:
只有四个客户端:
可以发现,所有客户端都能够和 server
建立起连接,进入 ESTABLISHED
状态。
backlog = 1
(MacOS)
只有一个客户端:
只有两个客户端:
只有三个客户端:
只有四个客户端:
可以发现,在客户端增加的过程中,前两个客户端都能够与 server
建立连接(到达 ESTABLISHED
状态) ,而后两个客户端只能停留在 SYN_SENT
状态,也找不到服务端向它们的逆连接。
这是因为 backlog = 1
的时候,ESTABLISHED
队列只有 \(1\) 个位置。第一个客户端在 ESTABLISHED
之后,很快被服务端调用 accept
进行处理了,不再占用 ESTABLISHED
队列的位置。第二个客户端在 ESTABLISHED
之后将会一直占用队列,而等不到服务端处理。
第三、第四个客户端因为 ESTABLISHED
队列已满,因此一直无法进入 ESTABLISHED
状态,只能停留在 SYN_SENT
状态,等待来自服务端的确认,但是一直等不到。
其实可以发现,第三、第四个客户端,因为一直等不到服务端的确认,所以很快就会自动 Timed out
:
Linux
在和同学比对实验结果的时候,我发现了我本地的 MacOS
对于 backlog
的处理结果似乎和 Linux 端不太相同。
因此,我启动了我的 Ubuntu 服务器,在服务器上面使用同样的代码进行了测试。这里直接贴出在以此启动四个客户端以后,查看 netstat
的结果:
当 backlog = 0
时,
可以发现,此时有两个客户端成功建立起连接,进入 ESTABLISHED
状态,而还有两个客户端被卡在了 SYN_SENT
状态。
实际上,这和我的 MacOS 下的 backlog = 1
时的结果是相同的。
当 backlog = 1
时,
此时,有三个客户端成功建立起连接,进入 ESTABLISHED
状态,而只有一个客户端被卡在了 SYN_SENT
状态。
结论
根据我查询了一些资料所知,listen
调用的 backlog
只是对于完成队列长度的一个建议值,并不能完全决定队列的真实长度。
对于 backlog = 0
的处理,不同的系统是有不同的方法的。实际上完成队列不可能真的为 \(0\)(否则就完全无法建立起任何连接了)。
所以 MacOS 采取的策略是,如果 backlog = 0
就忽略这个建议,使用默认的队列长度,而对于其他合法的给定长度都严格执行。因此在上面的实验中,MacOS 下的结果是 backlog = 0
时所有连接都可以建立(因为实际上的队列长度是默认值,远大于 \(4\)),而 backlog = 1
时队列长度只是 \(1\),最多建立两个连接。
而 Linux 是默认采用 backlog + 1
长度的队列,因此在 Linux 的实验中,baclog = 0
时可以建立起两个连接,而 backlog = 1
时可以建立起三个连接。
字节顺序转换
端口为什么要进行字节顺序转换?不转换会有什么情况?
提示:运行不转换代码进行对比。
网络字节顺序是一种规定好的字节顺序,通常采用大端序。而不同的计算机体系结构有可能采用不同的字节顺序,在许多常见的计算机中,往往都是采用的小端序。
如果在网络通信中不进行字节顺序转换,可能会导致接收方解释端口号时出现错误,因为接收方期望端口号采用网络字节顺序。
为了进行实验,我们将客户端的端口设置修改如下:
server_addr.sin_port = atoi(argv[2]);
可以发现:
如果只有客户端的端口号没有采用网络字节顺序,我们将无法建立起客户端到服务器的连接。
如果客户端和服务器均没有进行字节顺序的转换,我们可以发现连接又可以正常建立了:
这是因为两个进程在字节顺序上达成了统一。
而在大部分的网络进程中,都默认网络传输采用大端顺序,因此为了确保能够和所有的网络进程通信,一定要进行网络字节顺序的转换。
客户端固定端口
在客户端的 Connect
调用之前加入下面的代码:
/* 指定客户端地址 */
struct sockaddr_in client_addr;
client_addr.sin_family = AF_INET;
client_addr.sin_port = htons(23456);
client_addr.sin_addr.s_addr = htonl(INADDR_ANY); //INADDR_ANY表示本机所有IP地址
memset(&client_addr.sin_zero, 0, sizeof(client_addr.sin_zero)); //零填充
/* 绑定socket与地址 */
Bind(client_sock, (struct sockaddr *)&client_addr, sizeof(client_addr));
这将客户端的端口绑定为 23456
。
第一次运行:
第二次运行:
可以发现,两次运行的客户端端口都是 23456
,这与我们指定的一致。
任务 2 课程表查询服务器(TCP迭代)
服务器接收客户机发来的学号等信息,查询后发送给客户机,依次循环……
服务器迭代地处理客户机请求:查询给定的testlist,将结果回复客户机;一个客户机退出后、继续接受下一个,按Ctrl+C可以终止服务器程序。
虽然我觉得这个任务的主要难点在于写代码处理文件中的课程表信息……但是这个部分和计算机网络没啥关系,因此略过。(另外,本程序对于来自客户端的错误请求做了详尽的处理)
那么剩下的难点在于如何进行迭代处理。
这其实很简单:
while (1) {
printf("Server is listening....\n");
server_sock_data = Accept(server_sock_listen, NULL, NULL);
printf("Accept a client\n");
process(server_sock_data, course, stu_num);
}
我们在 Accept
调用外面包装一层 while
死循环,使得在一个客户端处理退出后,可以接着循环接受并处理下一个客户端。
process
函数是处理单个套接字的请求,在其内部是一个 while
循环不断地从套接字接受输入,然后处理,发送回去。
结果截图
虽然实验要求任务一和任务二是相同的,但是我觉得任务要求的第 2, 3, 4 三点其实只要在一个任务中做完就可以得到结论了,因此任务二似乎没有重做一遍剩下三项的必要?
任务 3 程表查询服务器(TCP并发)
在任务2的基础上修改服务器代码,以多进程的方式提供并发服务,实现“同时”与多个客户机交替对话
本任务的程序见 server3.c
和 client3.c
。
其实也很简单,只需要在每次 Accept
一个连接之后,fork
一个子进程,在子进程中进行连接的处理,而在主进程中,仍然继续 while
循环获得新的连接。
while (1) {
struct sockaddr_in *client_addr = (struct sockaddr_in *)malloc(sizeof(struct sockaddr_in));
static socklen_t client_addr_len = sizeof(struct sockaddr_in);
server_sock_data = Accept(server_sock_listen, (struct sockaddr *)client_addr, &client_addr_len);
printf("Accept %s:%d\n", inet_ntoa(client_addr->sin_addr), ntohs(client_addr->sin_port));
if (Fork() == 0) {
Close(server_sock_listen);
process(server_sock_data, course, stu_num, client_addr);
exit(0);
} else {
free(client_addr);
Close(server_sock_data);
}
}
有一点值得注意:因为在任务的实例截图中,我发现需要输出客户端的地址和端口号,因此这里 accept
调用的第二个参数和第三个参数不能留空了。
因此,我 malloc
了一个 client_addr
指针,指向 sockaddr_in
对象,然后传入 accept
中,在调用完成后,sockaddr_in
对象中就会有客户端的地址和端口号了。
本来我是准备 accept
第三个参数 addr_len
留空的,但是似乎在第二个参数不为空的时候,第三个参数也不能为空,因此必须传入一个指针。
结果截图
回答问题:
服务器accept之后会返回一个用于传输数据的socket,调用fork()会使父子进程同时拥有此socket描述符,父进程分支中是否需要关闭该socket?
理论上是需要的,因为父进程不应该再使用这个 socket 描述符。
父进程通常负责监听连接和接受新的连接请求,而子进程负责处理实际的数据传输。如果父进程和子进程都保留对相同 socket 描述符的引用,可能会导致一些问题,比如数据传输的竞争条件,或者造成资源泄漏。
分别试验其关闭和不关闭的代码,看运行是否正常;netstat观察多个客户机退出后的连接状态,考虑系统资源分配……
但是在实际测试中,服务端的正常运行以及与客户端的通信都没有出现问题。
即使将 Close(server_sock_data);
注释掉,也能够正常完成任务。
通过 netstat
观察,下面是三个客户端同时打开的状态:
而在关闭一个客户端后:
三个客户端全部关闭后:
与之对比的,如果父进程关闭了 socket 描述符,在关闭客户端后,三个连接应该都处于 TIME_WAIT
状态。
我对此的分析是,因为关闭连接是客户端发起的,在服务端子进程检测到客户端关闭了连接后,也会关闭自己的 socket 套接字。但是,父进程仍然持有这个 socket,因此服务端套接字认为可能还会继续向客户端发送数据,因此不会发送 FIN = 1, ACK = 1, seq = w, ack = u + 1
来通知客户端释放连接,因此服务端卡在了 CLOSE_WAIT
,而客户端卡在了 FIN_WAIT_2
。
因此,如果父进程不关闭套接字,会导致在客户端退出后端口的持续占用,从而引起资源的浪费。所以我们应该关闭父进程的 socket 描述符。
另外,我觉得还有一个必须要关闭 socket 套接字的理由。socket 描述符在 Unix 的处理中仍然是一种特殊的文件描述符,而一个进程能打开的文件描述符数量是有限的(而且非常少),因此如果在父进程中不关闭 socket 描述符,会导致如果客户端变多,将没有合适的描述符编号可以分配。
任务 4 获取网页内容
编写程序,建立与 www.people.com.cn 的tcp连接,请求网页的内容并保存。
步骤:使用Socket建立对指定URL的连接,使用HTTP的GET指令,获取网页的内容。
任务:
- 将网页的内容以字符串形式保存在txt文件中。
- (选做)将网页中的图片保存到本地文件夹
本任务的程序见 pyget.py
,得到的网页在 people.txt
中,得到的图片在 people_images
(不要下载,这个文件夹太大了,我没有上传)文件夹中。
实现思路很简单,和 C 版本的基本上一样,只是换了种语言。
大概得思路就是调用 socket.socket
建立一个 TCP 连接的套接字,然后 socket.connect
连接到网站的 80 端口。
然后发送一个 HTTP GET 请求到服务器,请求服务器的主页。
服务器会响应请求,返回主页的HTML内容。我们的程序接收这个响应,并将其保存到一个变量 data
中,因为 TCP 协议不一定一次把所有数据发完,所以使用 while
循环接受数据,直到没有数据接收了为止,则关闭与服务器的连接。
那么 data
就是我们获得的内容。但是这个 data 里面还会有很多的 HTTP Headers,我们要将其删去,这里我是找到了连续的 \r\n\r\n
(headers 和内容之间会有一个空行),只保留后面的部分,将之保存在 people.txt
中。
然后找到网页中所有图片比较复杂,我是在网页中使用 '<img.*?src="(.*?)".*?>'
进行正则匹配,找到图片的链接,然后对于每一个图片链接,程序再次建立一个TCP连接到服务器,并发送一个HTTP GET请求,请求图片数据,并将服务器的这个响应保存到本地文件夹中。
四、实验总结
这次计算机网络实验给我提供了一次深入学习的机会,通过实际动手操作,我更加深刻地理解了TCP和UDP协议的运作原理,以及socket编程的实际应用。在完成任务的过程中,我遇到了一些挑战,但也因此得到了更多的经验和知识。
任务一中,我对socket客户端进行了改进,实现了更加灵活的用户输入和循环发送功能。通过设置backlog
参数,我进一步了解了连接队列的处理,特别是在MacOS和Linux下的差异,让我更清晰地认识到不同操作系统对于网络编程的实现可能存在的细微差异。
任务二和任务三让我进一步深入socket编程,实现了一个简单的课程表查询服务器。通过TCP迭代和并发的方式提供服务,我学到了在多进程环境中处理客户端请求的方法。这使我对进程间通信和并发编程有了更深刻的理解。
在任务四中,我尝试用Python实现了与网站建立TCP连接,请求网页内容并保存的功能。这不仅锻炼了我对HTTP请求和响应过程的理解,也展示了不同编程语言在网络编程方面的灵活性。
这次实验让我在网络编程方面取得了实质性的进展。通过不断调试和解决问题,我更加熟练地掌握了socket编程的技巧,并对计算机网络的一些关键概念有了更加深入的理解。这是一次对理论知识的巩固与实践经验的积累的有益之旅。