失眠网,内容丰富有趣,生活中的好帮手!
失眠网 > 「网络编程」第二讲:网络编程socket套接字(三)_ 简单TCP网络通信程序的实现

「网络编程」第二讲:网络编程socket套接字(三)_ 简单TCP网络通信程序的实现

时间:2020-05-22 03:57:20

相关推荐

「网络编程」第二讲:网络编程socket套接字(三)_ 简单TCP网络通信程序的实现

「前言」文章是关于网络编程的socket套接字方面的,上一篇是网络编程socket套接字(二),下面开始讲解!

「归属专栏」网络编程

「主页链接」个人主页

「笔者」枫叶先生(fy)

「枫叶先生有点文青病」「每篇一句」

Idonotknowwheretogo,butIhavebeenontheroad.

我不知道将去何方,但我已在路上。

——宫崎骏《千与千寻》

目录

四、简单的TCP网络程序

4.1服务端创建

4.1.1创建套接字

4.1.2 绑定端口

4.1.3监听

4.1.4 获取新链接

4.1.5 服务端代码(版本一)

4.2客户端创建

4.2.1 创建套接字

4.2.2 连接服务器

4.2.3 客户端代码

4.3 服务端和客户端测试

4.4 多进程版的TCP网络程序

4.4.1 方法一:捕捉SIGCHLD信号

4.4.2 方法二:孙子进程

4.5 多线程版的TCP网络程序

4.6线程池版的TCP网络程序

四、简单的TCP网络程序

首先回顾一下TCP的特点:

传输层协议有连接可靠传输面向字节流

注:到TCP原理再详细解释这些特点,这里只是简单了解

接下来进行编写socket套接字代码,使用的是TCP,也是边写代码边讲一下TCP的接口,还有一些原理。

4.1服务端创建

首先明确,这个简单的TCP网络程序分客户端和服务端,所以我们要生成两个可执行程序,一个是客户端的,另一个是服务端的,服务端充当的是服务器,暂时实现的功能是客户端和服务端简单进行通信,服务端要可以收到客户端发送给服务端的信息,并把消息回显给客户端,目前就先简单实现这样的功能

下面进行编写服务端的代码

4.1.1创建套接字

创建套接字的函数是socket,TCP/UDP均可使用该函数进行创建套接字,该函数在上一篇编写UDP代码的时候已经详细介绍过了,这里就不再介绍,直接使用

int socket(int domain, int type, int protocol);

第一个参数:如果要选择网络通信,则选择AF_INET(IPv4)或者AF_INET6(IPv6)第二个参数:如果是基于TCP的网络通信,我们采用的就是SOCK_STREAM,流式套接字,提供的是流式服务(对应TCP的特点:面向字节流)第三个参数:设置为0表示的就是默认,此时会根据传入的前两个参数自动推导出你最终需要使用的是哪种协议

服务端创建套接字编写代码暂时如下:

tcpServer.hpp

#pragma once#include <iostream>#include <string>#include <sys/types.h>#include <sys/socket.h>using namespace std;// 错误类型枚举enum{UAGE_ERR = 1,SOCKET_ERR,BIND_ERR};class tcpServer{public:tcpServer(const uint16_t &port): _sockfd(-1), _port(port){}// 初始化服务器void initServer(){// 1.创建套接字_sockfd = socket(AF_INET, SOCK_STREAM, 0);if (_sockfd == -1){cout << "create socket error" << endl;exit(SOCKET_ERR);}cout << "socket success: " << _sockfd << endl;}// 启动服务器void start(){}~tcpServer(){}private:int _sockfd; // 文件描述符uint16_t _port; // 端口号};

注:创建套接字失败,没必要继续执行代码了,直接退出程序即可

#include "tcpServer.hpp"#include <memory>// 使用手册// ./tcpServer portstatic void Uage(string proc){cout << "\nUage:\n\t" << proc << " local_port\n\n";}int main(int argc, char *argv[]){if (argc != 2){Uage(argv[0]);exit(UAGE_ERR);}uint16_t port = atoi(argv[1]); // string to intunique_ptr<tcpServer> tsvr(new tcpServer(port));tsvr->initServer(); // 初始化服务器tsvr->start();// 启动服务器return 0;}

注:

TCP服务器创建套接字的做法与UDP服务器是一样的,只不过创建套接字时TCP需要的是流式服务,而UDP需要的是用户数据报服务析构服务器时,可以将服务器对应的 _sockfd 文件描述符进行关闭,也可以不用处理,因为服务器的生命周期随进程,服务器一般是我们手动结束的,进程结束,资源也就归还给OS了

4.1.2 绑定端口

TCP 也是需要绑定端口号的,绑定端口号的函数是bind,TCP/UDP均可使用进行该函数绑定端口

该函数在上一篇编写UDP代码的时候已经详细介绍过了,这里就不再介绍,直接使用

int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

bind函数的第一个参数是sockfd,用于绑定套接字创建成功返回的文件描述符bind函数的第二个参数是addr,用于填充网络相关的属性信息,比如IP地址、端口号等。我们要做的工作就是:定义一个sockaddr_in 的结构体,然后对该结构体进行内容填充,填完就把给结构体传给 第二个参数addr,需要强制类型转换第三参数addrlen:传入的addr结构体的长度

注:当定义好 sockaddr_in 结构体后,最好先用memset函数对该结构体进行清空,也可以用bzero函数进行清空。bzero函数也可以对特定的一块内存区域进行清空,bzero函数的函数原型如下:

void bzero(void *s, size_t n);

bzero()函数将从s开始的区域的前n个字节设置为零(包含“\0”的字节)。

头文件是:

#include <strings.h>

注:绑定失败,就直接退出程序了,不必要再执行

服务端绑定编写代码如下:

tcpServer.hpp

// 初始化服务器void initServer(){// 1.创建套接字_sockfd = socket(AF_INET, SOCK_STREAM, 0);if (_sockfd == -1){cout << "create socket error" << endl;exit(SOCKET_ERR);}cout << "create socket success: " << _sockfd << endl;// 2.绑定端口// 2.1 填充 sockaddr_in 结构体struct sockaddr_in local;bzero(&local, sizeof(local)); // 把 sockaddr_in结构体全部初始化为0local.sin_family = AF_INET; // 未来通信采用的是网络通信local.sin_port = htons(_port);// htons(_port)主机字节序转网络字节序local.sin_addr.s_addr = INADDR_ANY; // INADDR_ANY 就是 0x00000000// 2.2 绑定int n = bind(_sockfd, (struct sockaddr *)&local, sizeof(local)); // 需要强转,(struct sockaddr*)&localif (n == -1){cout << "bind socket error" << endl;exit(BIND_ERR);}cout << "bind socket success" << endl;}

没有改变

注意:设置服务器的IP地址时,不需要显示绑定IP地址,直接将IP地址设置INADDR_ANY (全0),这个问题在上一节已经谈过

说一下前面UDP发送和接收的问题

因为UDP是面向数据报的,所以要用特定的接口进行收发消息。

为什么发送没有进行主机序列转为网络序列,接收消息没有把网络序列转为主机序列??

因为 recvfrom 和 sendto 是系统调用,这两个函数在函数内部已经帮我们做了,即主机序列转为网络序列和网络序列转为主机序列的工作

4.1.3监听

UDP服务器的初始化操作只有两步,第一步就是创建套接字,第二步就是绑定。而TCP服务器是面向连接的,客户端在正式向TCP服务器发送数据之前,需要先与TCP服务器建立连接,然后才能与服务器进行通信

因此TCP服务器需要时刻注意是否有客户端发来连接请求,此时就需要将TCP服务器创建的套接字设置为监听状态

listen函数

listen函数的作用是设置套接字为监听状态,man 2 listen查看:

listen for connections on a socket:监听套接字上的连接

函数:listen头文件:#include <sys/types.h>#include <sys/socket.h>函数原型:int listen(int sockfd, int backlog);参数:第一个参数sockfd:需要设置为监听状态的套接字对应的文件描述符第二个参数backlog:全连接队列的最大长度返回值:成功返回0,失败返回-1,同时错误码会被设置

注意:

第二个参数backlog:全连接队列的最大长度。。如果有多个客户端同时发来连接请求,此时未被服务器处理的连接就会放入连接队列,该参数代表的就是这个全连接队列的最大长度,一般不能设置太大这个参数暂时解释不了,等后面讲 TCP协议原理的时候再谈,TODO这个参数暂时直接设置为5

TCP服务器在创建完套接字和绑定后,需要再进一步将套接字设置为监听状态,监听是否有新的连接到来。如果监听失败也没必要进行后续操作了,因为监听失败也就意味着TCP服务器无法接收客户端发来的连接请求,因此监听失败我们直接终止程序即可

服务监听代码编写如下:

tcpServer.hpp

static const int gbacklog = 5;// 初始化服务器void initServer(){// 1.创建套接字_sockfd = socket(AF_INET, SOCK_STREAM, 0);if (_sockfd == -1){cout << "create socket error" << endl;exit(SOCKET_ERR);}cout << "create socket success: " << _sockfd << endl;// 2.绑定端口// 2.1 填充 sockaddr_in 结构体struct sockaddr_in local;bzero(&local, sizeof(local)); // 把 sockaddr_in结构体全部初始化为0local.sin_family = AF_INET; // 未来通信采用的是网络通信local.sin_port = htons(_port);// htons(_port)主机字节序转网络字节序local.sin_addr.s_addr = INADDR_ANY; // INADDR_ANY 就是 0x00000000// 2.2 绑定int n = bind(_sockfd, (struct sockaddr *)&local, sizeof(local)); // 需要强转,(struct sockaddr*)&localif (n == -1){cout << "bind socket error" << endl;exit(BIND_ERR);}cout << "bind socket success" << endl;// 3. 把_sockfd套接字设置为监听状态if(listen(_sockfd, gbacklog) == -1){cout << "listen socket error" << endl;exit(LISTEN_ERR);}cout << "listen socket error" << endl;}

没有变化

在初始化TCP服务器时,只有创建套接字成功、绑定成功、监听成功,此时TCP服务器的初始化才算完成

4.1.4 获取新链接

上面的代码已经把服务器初始化完成了,客户端有新链接到来,服务端可以获取到新链接,这一步需要死循环获取客户端新链接

获取新链接的函数是 accept

accept函数

accept 函数的作用是用于获取客户端的链接,man 2 accept查看:

ccept a connection on a socket:接受套接字上的连接

函数:accept头文件:#include <sys/types.h>#include <sys/socket.h>函数原型:int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);参数:第一个参数sockfd:从该监听套接字中获取连接第二个参数addr:对方一端网络相关的属性信息第三个参数addrlen:addr的长度返回值:获取连接成功返回接收到的套接字的文件描述符,获取连接失败返回-1,同时错误码会被设置

accept 的第二个参数是addr,类型是struct sockaddr *第二个参数是addr 和第三个参数addrlen 是一个输入输出型参数第二个参数addr 用于返回发送数据一方的信息,比如IP、端口号等。就好比别人发消息给你,你得知道对方是谁我们要做的也是定义一个sockaddr_in 的结构体,初始化该结构体,把结构体传给第二个参数addr,需要强制类型转换

accept函数返回值问题

accept获取连接成功返回接收到的套接字的文件描述符

问题:为什么又返回一个新的文件描述符??返回的这个新的文件描述符跟旧的文件描述符_sockfd有什么关系??

下面用一个小栗子进行说明,比如有一家小餐厅,张三是站在店外面招揽客人的,有客人的到来张三就说:我们这店是这里最好的,您要进来吃吗?有的客人进去的,有的客人没进去。而进去的客人,店里面又会叫服务员A、B、C...进行一对一服务。而张三并没有进去,依旧是在外面招揽客人

这个揽客的张三就相当于旧的文件描述符_sockfd,不断获取客户端发来的连接请求;这些客户就是客户端的客户,向服务端发起请求;服务员A、B、C...就是新创建的文件描述符,新创建的文件描述符的核心工作就是提供服务有服务员A、B、C...就意味着,文件描述符会随用户的增多而增长

listen监听套接字与accept函数返回的套接字的作用(新创建的文件描述符):

listen监听套接字:用于获取客户端发来的连接请求。accept函数会不断从监听套接字当中获取新连接accept函数返回的套接字:用于为本次accept获取到的连接提供服务。而listen监听套接字的任务只是不断获取新连接,而真正为这些连接提供服务的套接字是accept函数返回的套接字,而不是监听套接字

所以代码中的 _sockfd 套接字全部改为 _listensock

服务获取新链接代码编写如下:

未来通信就使用sockfd

tcpServer.hpp

// 启动服务器void start(){for (;;){// 4. 获取新链接,accept从_listensock套接字里面获取新链接struct sockaddr_in peer;socklen_t len = sizeof(peer);// 这里的sockfd才是真正为客户端请求服务int sockfd = accept(_listensock, (struct sockaddr *)&peer, &len);if (sockfd < 0) // 获取新链接失败,但不会影响服务端运行{cout << "accept error, next!" << endl;continue;}cout << "accept a new line success" << endl;}}

服务端在获取连接时需要注意:accept函数获取连接时可能会失败,但TCP服务器不会因为获取某个连接失败而退出,因此服务端获取连接失败后应该继续获取连接

没有变化

4.1.5 服务端代码(版本一)

接下来就是补充完整服务端的代码了,收取客户端发来的消息,并回显给客户端

后序通信就使用sockfd,而这个sockfd是面向字节流的,也就意味后序的操作全部是文件操作,也就是进行文件读写

服务端代码如下:

tcpServer.hpp

#pragma once#include <iostream>#include <string>#include <strings.h>#include <unistd.h>#include <sys/types.h>#include <sys/socket.h>#include <arpa/inet.h>using namespace std;static const int gbacklog = 5;// 错误类型枚举enum{UAGE_ERR = 1,SOCKET_ERR,BIND_ERR,LISTEN_ERR};class tcpServer{public:tcpServer(const uint16_t &port): _listensock(-1), _port(port){}// 初始化服务器void initServer(){// 1.创建套接字_listensock = socket(AF_INET, SOCK_STREAM, 0);if (_listensock == -1){cout << "create socket error" << endl;exit(SOCKET_ERR);}cout << "create socket success: " << _listensock << endl;// 2.绑定端口// 2.1 填充 sockaddr_in 结构体struct sockaddr_in local;bzero(&local, sizeof(local)); // 把 sockaddr_in结构体全部初始化为0local.sin_family = AF_INET; // 未来通信采用的是网络通信local.sin_port = htons(_port);// htons(_port)主机字节序转网络字节序local.sin_addr.s_addr = INADDR_ANY; // INADDR_ANY 就是 0x00000000// 2.2 绑定int n = bind(_listensock, (struct sockaddr *)&local, sizeof(local)); // 需要强转,(struct sockaddr*)&localif (n == -1){cout << "bind socket error" << endl;exit(BIND_ERR);}cout << "bind socket success" << endl;// 3. 把_listensock套接字设置为监听状态if (listen(_listensock, gbacklog) == -1){cout << "listen socket error" << endl;exit(LISTEN_ERR);}cout << "listen socket success" << endl;}// 启动服务器void start(){for (;;){// 4. 获取新链接,accept从_listensock套接字里面获取新链接struct sockaddr_in peer;socklen_t len = sizeof(peer);// 这里的sockfd才是真正为客户端请求服务int sockfd = accept(_listensock, (struct sockaddr *)&peer, &len);if (sockfd < 0) // 获取新链接失败,但不会影响服务端运行{cout << "accept error, next!" << endl;continue;}cout << "accept a new line success, sockfd: " << sockfd << endl;// 5. 为sockfd提供服务,即为客户端提供服务serviceIo(sockfd);// 走到这里。服务已经提供完成,必须关闭 sockfdclose(sockfd);}}// 提供服务void serviceIo(int sockfd){char buffer[1024];while (true){// 读取客户端发来的消息ssize_t n = read(sockfd, buffer, sizeof(buffer) - 1);if (n > 0) // 读取成功{buffer[n] = 0;cout << "recv a message: " << buffer << endl;// 回显消息给客户端string outbuffer = buffer;outbuffer += "server[echo]";write(sockfd, outbuffer.c_str(), outbuffer.size());}else if (n == 0) // 客户端退出{cout << "client qiut, me too!" << endl;break;}}}~tcpServer(){}private:int _listensock; // listen套接字,不是用来数据通信的,是用来监听链接到来uint16_t _port; // 端口号};

没有变化

注意

这里暂时使用 read、write 的接口,读取是有问题的,但是在这样先这样用,没有错,后序再使用 TCP 专用读写接口

服务端已经可以编译运行了,运行结果如下:

netstat -atlp查看

服务器已经处于监听状态了

netstat -atlpn 查看,n 以数字显示

注意:这个服务端读取是有问题的,后序再谈,目前就把内容当作字符串读取,也能满足目前需求

4.2客户端创建

客户端的功能是可以发送消息给服务端,并收到服务端回显的消息,目前就先简单实现这样的功能

4.2.1 创建套接字

客户端也是使用socket函数创建套接字,与TCP服务端一样

客户端创建套接字代码编写如下:

tcpServer.hpp

class tcpClient{public:tcpClient(const string &serverip, const uint16_t serverport): _serverip(serverip), _serverport(serverport), _sockfd(-1){}// 初始化客户端void initClient(){// 创建套接字_sockfd = socket(AF_INET, SOCK_STREAM, 0);if (_sockfd == -1){cerr << "socket create error" << endl;exit(2);}// 2.绑定// 客户端必须要进行bind绑定,但是不需要我们自己bind,OS帮我们完成// 3.listen// 客户端不需要listen// 4. accept//客户端不需要}// 启动客户端void start(){//5. 客户端需要发起链接,链接服务端}~tcpClient(){}private:uint16_t _serverport; // 端口号string _serverip;// ip地址int _sockfd;// 文件描述符};

注意:

客户端必须要进行bind绑定,但是不需要我们自己bind,OS帮我们完成客户端也不需要listen进行监听客户端也不需要accept获取新链接但是客户端需要发起链接,连接服务端

#include "tcpClient.hpp"#include <memory>// 使用手册// ./tcpClient ip portstatic void Uage(string proc){cout << "\nUage:\n\t" << proc << " server_ip server_port\n\n";}int main(int argc, char *argv[]){if (argc != 3){Uage(argv[0]);exit(1);}// 客户端需要服务端的 IP 和 portstring serverip = argv[1];uint16_t serverport = atoi(argv[2]); // string to intstd::unique_ptr<tcpClient> tcli(new tcpClient(serverip, serverport));tcli->initClient(); // 初始化服务器tcli->start(); // 启动服务器return 0;}

4.2.2 连接服务器

由于客户端不需要绑定,也不需要监听,因此当客户端创建完套接字后就可以向服务端发起连接请求。

connect函数

connect函数用于发起连接请求,man 2 connect查看:

initiate a connection on a socket:在套接字上启动连接

函数:connect头文件:#include <sys/types.h>#include <sys/socket.h>函数原型:int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);参数:第一个参数sockfd:表示通过该套接字发起连接请求第二个参数addr:对方一端网络相关的属性信息第三个参数addrlen:addr的长度返回值:连接或绑定成功返回0,连接失败返回-1,同时错误码会被设置

第二个参数是addr,表示对端网络相关的属性信息,包括协议家族、IP地址、端口号等。类型是struct sockaddr *第二个参数是addr 和第三个参数addrlen 是一个输入型参数

客户连接代码编写如下:

tcpServer.hpp

void start(){// 5. 客户端需要发起链接,链接服务端struct sockaddr_in server;memset(&server, 0, sizeof(server));server.sin_family = AF_INET;server.sin_port = htons(_serverport); // 主机转网络序列server.sin_addr.s_addr = inet_addr(_serverip.c_str()); // 1.string类型转int类型 2.把int类型转换成网络字节序 (这两个工作inet_addr已完成)if (connect(_sockfd, (struct sockaddr *)&server, sizeof(server)) != 0){cerr << "socket connect error" << endl;}else // 连接成功{}}

没有变化

4.2.3 客户端代码

接下来就是补充完整服务端的代码了,客户端可以发送消息给服务端,客户端并且可以接收服务端回显的消息

后序操作也全部是文件操作

客户端代码如下:

tcpServer.hpp

#pragma once#include <iostream>#include <string>#include <cstring>#include <strings.h>#include <unistd.h>#include <sys/types.h>#include <sys/socket.h>#include <arpa/inet.h>using namespace std;static const int gnum = 1024;class tcpClient{public:tcpClient(const string &serverip, const uint16_t serverport): _serverip(serverip), _serverport(serverport), _sockfd(-1){}// 初始化客户端void initClient(){// 创建套接字_sockfd = socket(AF_INET, SOCK_STREAM, 0);if (_sockfd == -1){cerr << "socket create error" << endl;exit(2);}// 2.绑定// 客户端必须要进行bind绑定,但是不需要我们自己bind,OS帮我们完成// 3.listen// 客户端不需要listen// 4. accept// 客户端不需要}// 启动客户端void start(){// 5. 客户端需要发起链接,链接服务端struct sockaddr_in server;memset(&server, 0, sizeof(server));server.sin_family = AF_INET;server.sin_port = htons(_serverport); // 主机转网络序列server.sin_addr.s_addr = inet_addr(_serverip.c_str()); // 1.string类型转int类型 2.把int类型转换成网络字节序 (这两个工作inet_addr已完成)if (connect(_sockfd, (struct sockaddr *)&server, sizeof(server)) != 0){cerr << "socket connect error" << endl;}else // 连接成功{string message;while (true){// 发送消息cout << "Enter# ";getline(cin, message);write(_sockfd, message.c_str(), message.size());// 接收服务端回显的消息char buffer[gnum];int n = read(_sockfd, buffer, sizeof(buffer) - 1);if (n > 0) // 读取成功{buffer[n] = 0;cout << "Server回显# " << buffer << endl;}else // 读取出错{break; }}}}~tcpClient(){}private:uint16_t _serverport; // 端口号string _serverip;// ip地址int _sockfd;// 文件描述符};

没有变化

注意:这个服务端读取是有问题的,后序再谈,目前就把内容当作字符串读取,也能满足目前需求

4.3 服务端和客户端测试

然后进行整体编译,编译没有问题

Makefile

新建窗口,先运行服务端,再启动客户端,客户端先用本地环回进行测试

其中文件描述符会随着客户端的链接而递增

发送消息测试,测试正常

查看一下进程信息

netstat -atlp查看

客户端和服务端在同一台机器上跑,就会查到三个:

如果是两台主机,在服务端就会查到两个信息:

第一个是服务器处于监听状态的第二个是其中处于链接状态的, ESTABLISHED 的意思是:建立连接;第二个的Foreign Address:127.0.0.1:40874,代表的是客户端的IP和端口号

如果是两台主机,在客户端就会查到一个信息:

测试,客户端关闭,服务端相应的文件描述符也要随之关闭

该服务器的弊端

当我们仅用一个客户端连接服务端时,这一个客户端能够正常享受到服务端的服务

但在这个客户端正在享受服务端的服务时,我们让另一个客户端也连接服务器,此时虽然在客户端显示连接是成功的,但这个客户端发送给服务端的消息既没有在服务端进行打印,服务端也没有将该数据回显给该客户端

只有当第一个客户端退出后,服务端才会将第二个客户端发来是数据进行打印,并回显该第二个客户端。

通过实验现象可以看到,这服务端只有服务完一个客户端后才会服务另一个客户端。因为我们目前所写的是一个单执行流版的服务器,这个服务器一次只能为一个客户端提供服务。

当服务端调用accept函数获取到连接后就给该客户端提供服务,但在服务端提供服务期间可能会有其他客户端发起连接请求,但由于当前服务器是单执行流的,只能服务完当前客户端后才能继续服务下一个客户端

客户端为什么会显示连接成功?

当服务端在给第一个客户端提供服务期间,第二个客户端向服务端发起的连接请求时是成功的,只不过服务端没有调用accept函数将该连接获取上

实际在底层会为我们维护一个连接队列,服务端没有accept的新连接就会放到这个连接队列当中,而这个连接队列的最大长度就是通过listen函数的第二个参数来指定的,因此服务端虽然没有获取第二个客户端发来的连接请求,但是在第二个客户端那里显示是连接成功的。

如何解决这个问题

单执行流的服务器一次只能给一个客户端提供服务,此时服务器的资源并没有得到充分利用,因此服务器一般是不会写成单执行流的。要解决这个问题就需要将服务器改为多执行流的,即多进程或多线程

4.4 多进程版的TCP网络程序

客户端没有变化,要改的是服务端代码

把当前的单执行流服务器改为多进程版的服务器

当服务端调用accept函数获取到新连接后不是由当前执行流为该连接提供服务,而是当前执行流调用fork函数创建子进程,然后让子进程为父进程获取到的连接提供服务。由于父子进程是两个不同的执行流,当父进程调用fork创建出子进程后,父进程就可以继续从监听套接字当中获取新连接,而不用关心获取上来的连接是否服务完毕。文件描述符表是隶属于一个进程的,子进程创建后会继承父进程的文件描述符表。比如父进程打开了一个文件,该文件对应的文件描述符是3,此时父进程创建的子进程的3号文件描述符也会指向这个打开的文件,而如果子进程再创建一个子进程,那么子进程创建的子进程的3号文件描述符也同样会指向这个打开的文件对于套接字文件也是一样的,父进程创建的子进程也会继承父进程的套接字文件,此时子进程就能够对特定的套接字文件进行读写操作,进而完成对对应客户端的服务

等待子进程问题

当父进程创建出子进程后,父进程是需要等待子进程退出的,否则子进程会变成僵尸进程,进而造成内存泄漏

阻塞式等待与非阻塞式等待子进程:

如果服务端采用阻塞的方式等待子进程,那么服务端还是需要等待服务完当前客户端,才能继续获取下一个连接请求,此时服务端仍然是以一种串行的方式为客户端提供服务。如果服务端采用非阻塞的方式等待子进程,虽然在子进程为客户端提供服务期间服务端可以继续获取新连接,但此时服务端就需要将所有子进程的PID保存下来,并且需要不断花费时间检测子进程是否退出。总之,服务端要等待子进程退出,无论采用阻塞式等待还是非阻塞式等待,都不尽人意。此时我们可以考虑让服务端不等待子进程退出

不等待子进程退出的方式

常见的方式有两种:

捕捉SIGCHLD信号,将其处理动作设置为忽略。让父进程创建子进程,子进程再创建孙子进程,最后让孙子进程为客户端提供服务

4.4.1 方法一:捕捉SIGCHLD信号

SIGCHLD信号是在子进程退出时由内核发送给父进程的信号。默认情况下,父进程会等待子进程退出并进行处理。但是,可以通过捕捉SIGCHLD信号并将其处理动作设置为忽略,来实现父进程不等待子进程退出的效果,这样父进程就不必关心子进程了

服务端代码修改如下

只需要更改start函数

// 启动服务器void start(){// 忽略SIGCHLD信号signal(SIGCHLD, SIG_IGN);for (;;){// 4. 获取新链接,accept从_listensock套接字里面获取新链接struct sockaddr_in peer;socklen_t len = sizeof(peer);// 这里的sockfd才是真正为客户端请求服务int sockfd = accept(_listensock, (struct sockaddr *)&peer, &len);if (sockfd < 0) // 获取新链接失败,但不会影响服务端运行{cout << "accept error, next!" << endl;continue;}cout << "accept a new line success, sockfd: " << sockfd << endl;// 5. 为sockfd提供服务,即为客户端提供服务// serviceIo(sockfd);// // 走到这里。服务已经提供完成,必须关闭 sockfd// close(sockfd);// 多进程版(忽略信号)pid_t id = fork();if (id == 0) // 子进程{close(_listensock);serviceIo(sockfd);close(sockfd);exit(0);}// 父进程无需等待}}

编译没有问题

新建窗口,先运行服务端,再启动客户端,客户端先用本地环回进行测试

这样服务端就可以响应多个客户端的请求

注:读取可能会出现问题,这个后序再谈

把客户端关掉,重新连接,文件描述符正常关闭

查看一下进程信息

ps axj | head -1 && ps axj | grep tcpServer | grep -v grep

这两个客户端分别由两个不同的执行流提供服务,因此这两个客户端可以同时享受到服务,它们发送给服务端的数据都能够在服务端输出,并且服务端也会对它们的数据进行响应

当客户端全部退出后,在服务端对应为之提供服务的子进程也会相继退出,但无论如何服务端都至少会有一个服务进程,这个服务进程的任务就是不断获取新连接

4.4.2 方法二:孙子进程

让父进程创建子进程,子进程再创建孙子进程,最后让孙子进程为客户端提供服务

对于父进程来说,子进程创建的进程与父进程的关系是孙子关系(站在父进程的视角)

由于子进程进程创建完孙子进程后就立刻退出了,因此实际为客户端提供服务的孙子进程就变成了孤儿进程,该进程就会被OS领养,当孙子进程为客户端提供完服务退出后OS会回收孙子进程,所以父进程是不需要等待孙子进程退出的

子进程需要关闭对应不用的文件描述符,否则就会造成文件描述符泄漏,或者子进程可能会对不需要的文件描述符进行某种误操作

父进程的accept函数获取到新连接后,会让孙子进程为该连接提供服务,此时服务进程已经将文件描述符表继承给了子进程,而子进程又会调用fork函数创建出孙子进程,然后再将文件描述符表继承给孙子进程子进程继承的文件描述符是一个副本,它们指向相同的文件表项。当父进程或子进程关闭文件描述符时,只会关闭其副本,并不会影响到另一个进程的文件描述符父子进程创建后,它们各自的文件描述符表是独立的,不会相互影响。因此父进程在调用fork函数后,子进程就不需要再关心从父进程继承下来的文件描述符_listensock,此时子进程就可以调用close函数将该文件描述符进行关闭,即_listensock不关闭就会造成文件描述符泄漏

父进程也需要关掉不用的文件描述符,否则就会造成文件描述符泄漏

服务端代码修改如下:

tcpServer.hpp

只需要更改start函数

// 启动服务器void start(){for (;;){// 4. 获取新链接,accept从_listensock套接字里面获取新链接struct sockaddr_in peer;socklen_t len = sizeof(peer);// 这里的sockfd才是真正为客户端请求服务int sockfd = accept(_listensock, (struct sockaddr *)&peer, &len);if (sockfd < 0) // 获取新链接失败,但不会影响服务端运行{cout << "accept error, next!" << endl;continue;}cout << "accept a new line success, sockfd: " << sockfd << endl;// 5. 为sockfd提供服务,即为客户端提供服务// serviceIo(sockfd);// // 走到这里。服务已经提供完成,必须关闭 sockfd// close(sockfd);// 多进程版(孙子进程)pid_t id = fork();if (id == 0) // 子进程{close(_listensock);// 创建孙子进程,让子进程退出if (fork() > 0)exit(0);// 孙子进程执行后序代码serviceIo(sockfd);close(sockfd);exit(0);}// 父进程pid_t ret = waitpid(id, nullptr, 0);if (ret > 0){cout << "wait success" << endl;}close(sockfd); // 必须关掉}}

编译没有问题

新建窗口,先运行服务端,再启动客户端,客户端先用本地环回进行测试

这样服务端就可以响应多个客户端的请求

注:读取可能会出现问题,这个后序再谈

把客户端关掉,重新连接,文件描述符正常关闭

查看一下进程信息

ps axj | head -1 && ps axj | grep tcpServer | grep -v grep

这两个客户端是由两个不同的孤儿进程提供服务的,因此它们也是能够同时享受到服务的,可以看到这两个客户端发送给服务端的数据都能够在服务端输出,并且服务端也会对它们的数据进行响应。

PPID为1,表明这是一个孤儿进程

当客户端全部退出后,对应为客户端提供服务的孤儿进程也会跟着退出,这时这些孤儿进程会被系统回收,而最终剩下那个获取连接的父进程

注:如果服务端先退,客户端后退,下一次运行服务端就会绑定端口失败,换一个端口绑定就好了,至于原理后序再谈

4.5 多线程版的TCP网络程序

频繁的创建进程会给OS带来巨大的负担,并且创建线程的成本比创建线程高得多。因此在实现多执行流的服务器时最好采用多线程进行实现。这块在多线程已经谈过,不再赘述

当服务进程调用accept函数获取到一个新连接后,就可以直接创建一个线程,让该线程为对应客户端提供服务。

主线程创建出新线程后,也是需要等待新线程退出的,否则也会造成类似于僵尸进程这样的问题。但对于线程来说,如果不想让主线程等待新线程退出,直接线程分离即可,当这个线程退出时系统会自动回收该线程所对应的资源。

主线程和新线程对文件描述符的态度

各个线程共享是同一张文件描述符表,也就是说服务进程(主线程)调用accept函数获取到一个文件描述符后,其他创建的新线程是能够直接访问这个文件描述符的。

文件描述符关闭的问题

对于主线程accept上来的文件描述符,主线程不能对其进行关闭操作,该文件描述符的关闭操作应该又新线程来执行。因为是新线程为客户端提供服务的,只有当新线程为客户端提供的服务结束后才能将该文件描述符关闭

注意:线程的回调方法在类内需要设置为静态,至于原因在linux系统编程多线程已经解释过了,不再赘述

tcpServer.hpp

#pragma once#include <iostream>#include <string>#include <strings.h>#include <unistd.h>#include <pthread.h>#include <sys/types.h>#include <sys/socket.h>#include <arpa/inet.h>using namespace std;static const int gbacklog = 5;// 错误类型枚举enum{UAGE_ERR = 1,SOCKET_ERR,BIND_ERR,LISTEN_ERR};class tcpServer; // 声明class ThreadDate{public:ThreadDate(tcpServer *self, int sockfd): _self(self), _sockfd(sockfd){}public:tcpServer *_self;int _sockfd;};class tcpServer{public:tcpServer(const uint16_t &port): _listensock(-1), _port(port){}// 初始化服务器void initServer(){// 1.创建套接字_listensock = socket(AF_INET, SOCK_STREAM, 0);if (_listensock == -1){cout << "create socket error" << endl;exit(SOCKET_ERR);}cout << "create socket success: " << _listensock << endl;// 2.绑定端口// 2.1 填充 sockaddr_in 结构体struct sockaddr_in local;bzero(&local, sizeof(local)); // 把 sockaddr_in结构体全部初始化为0local.sin_family = AF_INET; // 未来通信采用的是网络通信local.sin_port = htons(_port);// htons(_port)主机字节序转网络字节序local.sin_addr.s_addr = INADDR_ANY; // INADDR_ANY 就是 0x00000000// 2.2 绑定int n = bind(_listensock, (struct sockaddr *)&local, sizeof(local)); // 需要强转,(struct sockaddr*)&localif (n == -1){cout << "bind socket error" << endl;exit(BIND_ERR);}cout << "bind socket success" << endl;// 3. 把_listensock套接字设置为监听状态if (listen(_listensock, gbacklog) == -1){cout << "listen socket error" << endl;exit(LISTEN_ERR);}cout << "listen socket success" << endl;}// 启动服务器void start(){for (;;){// 4. 获取新链接,accept从_listensock套接字里面获取新链接struct sockaddr_in peer;socklen_t len = sizeof(peer);// 这里的sockfd才是真正为客户端请求服务int sockfd = accept(_listensock, (struct sockaddr *)&peer, &len);if (sockfd < 0) // 获取新链接失败,但不会影响服务端运行{cout << "accept error, next!" << endl;continue;}cout << "accept a new line success, sockfd: " << sockfd << endl;// 5. 为sockfd提供服务,即为客户端提供服务// serviceIo(sockfd);// // 走到这里。服务已经提供完成,必须关闭 sockfd// close(sockfd);// 多线程版pthread_t tid;ThreadDate *td = new ThreadDate(this, sockfd);pthread_create(&tid, nullptr, threadRoutine, td);}}static void *threadRoutine(void *args){pthread_detach(pthread_self()); // 线程分离ThreadDate *td = static_cast<ThreadDate *>(args);td->_self->serviceIo(td->_sockfd);close(td->_sockfd); // 必须关闭,由新线程关闭delete td;return nullptr;}// 提供服务void serviceIo(int sockfd){char buffer[1024];while (true){// 读取客户端发来的消息ssize_t n = read(sockfd, buffer, sizeof(buffer) - 1);if (n > 0) // 读取成功{buffer[n] = 0;cout << "recv a message: " << buffer << endl;// 回显消息给客户端string outbuffer = buffer;outbuffer += " server[echo]";write(sockfd, outbuffer.c_str(), outbuffer.size());}else if (n == 0) // 客户端退出{cout << "client qiut, me too!" << endl;break;}}}~tcpServer(){}private:int _listensock; // listen套接字,不是用来数据通信的,是用来监听链接到来uint16_t _port; // 端口号};

其他没有发送变化

注意:编译要带 -lpthread,因为使用了线程库

注意一下:前面客户端代码写漏了一个.... 刚发现。

编译没有问题

新建窗口,先运行服务端,再启动客户端,客户端先用本地环回进行测试

这样服务端就可以响应多个客户端的请求

把客户端关掉,重新连接,文件描述符正常关闭

查看一下线程信息

ps -aL

当客户端全部退出后,服务端的服务线程也随之退出

4.6线程池版的TCP网络程序

多线程存在的问题

每当有新连接到来时,服务端的主线程都会重新为该客户端创建为其提供服务的新线程,而当服务结束后又会将该新线程销毁。这样做不仅麻烦,而且效率低下,每当连接到来的时候服务端才创建对应提供服务的线程。如果有大量的客户端连接请求,此时服务端要为每一个客户端创建对应的服务线程。计算机当中的线程越多,CPU的压力就越大

解决方法:线程池

可以在服务端预先创建一批线程,当有客户端请求连接时就让这些线程为客户端提供服务,此时客户端一来就有线程为其提供服务,而不是当客户端来了才创建对应的服务线程(频繁创建线程的开销)当某个线程为客户端提供完服务后,不要让该线程退出,而是让该线程继续为下一个客户端提供服务,如果当前没有客户端连接请求,则可以让该线程先进入休眠状态,当有客户端连接到来时再将该线程唤醒。服务端创建的这一批线程的数量不能太多,此时CPU的压力也就不会太大

需要在服务端引入线程池,因为线程池的存在就是为了避免处理短时间任务时创建与销毁线程的代价,此外,线程池还能够保证内核充分利用,防止过分调度

线程池在系统编程部分已经谈过,不再赘述

服务端代码编写如下:

#pragma once#include <iostream>#include <string>#include <memory>#include <strings.h>#include <unistd.h>#include <sys/types.h>#include <sys/socket.h>#include <arpa/inet.h>#include "ThreadPool.hpp"#include "Task.hpp"using namespace std;static const int gbacklog = 5;// 错误类型枚举enum{UAGE_ERR = 1,SOCKET_ERR,BIND_ERR,LISTEN_ERR};class tcpServer{public:tcpServer(const uint16_t &port): _listensock(-1), _port(port){}// 初始化服务器void initServer(){// 1.创建套接字_listensock = socket(AF_INET, SOCK_STREAM, 0);if (_listensock == -1){cout << "create socket error" << endl;exit(SOCKET_ERR);}cout << "create socket success: " << _listensock << endl;// 2.绑定端口// 2.1 填充 sockaddr_in 结构体struct sockaddr_in local;bzero(&local, sizeof(local)); // 把 sockaddr_in结构体全部初始化为0local.sin_family = AF_INET; // 未来通信采用的是网络通信local.sin_port = htons(_port);// htons(_port)主机字节序转网络字节序local.sin_addr.s_addr = INADDR_ANY; // INADDR_ANY 就是 0x00000000// 2.2 绑定int n = bind(_listensock, (struct sockaddr *)&local, sizeof(local)); // 需要强转,(struct sockaddr*)&localif (n == -1){cout << "bind socket error" << endl;exit(BIND_ERR);}cout << "bind socket success" << endl;// 3. 把_listensock套接字设置为监听状态if (listen(_listensock, gbacklog) == -1){cout << "listen socket error" << endl;exit(LISTEN_ERR);}cout << "listen socket success" << endl;}// 启动服务器void start(){// 初始化线程池unique_ptr<ThreadPool<Task>> tp(new ThreadPool<Task>());tp->run();for (;;){// 4. 获取新链接,accept从_listensock套接字里面获取新链接struct sockaddr_in peer;socklen_t len = sizeof(peer);// 这里的sockfd才是真正为客户端请求服务int sockfd = accept(_listensock, (struct sockaddr *)&peer, &len);if (sockfd < 0) // 获取新链接失败,但不会影响服务端运行{cout << "accept error, next!" << endl;continue;}cout << "accept a new line success, sockfd: " << sockfd << endl;// 5. 为sockfd提供服务,即为客户端提供服务// 构建任务tp->push(Task(sockfd, serviceIo));}}~tcpServer(){}private:int _listensock; // listen套接字,不是用来数据通信的,是用来监听链接到来uint16_t _port; // 端口号};

由于代码过多就不一一贴出来了,上传到Gitee

Gitee链接:code_linux/code_06_16/2_tcp/4_tcpthpool · Maple_fylqh/code - 码云 - 开源中国 ()

代码测试

编译没有问题

新建窗口,先运行服务端,再启动客户端,客户端先用本地环回进行测试

把客户端关掉,重新连接,文件描述符正常关闭

pa -aL查看一下线程信息

运行服务端后,就算没有客户端发来连接请求,此时在服务端就已经有了6个线程,其中有一个是接收新连接的服务线程,而其余的5个是线程池当中为客户端提供服务的线程

与之前不同的是,无论现在有多少客户端发来请求,在服务端都只会有线程池当中的5个线程为之提供服务,线程池当中的线程个数不会随着客户端连接的增多而增多,这些线程也不会因为客户端的退出而退出,也就是说目前写的代码只适合用户访问量少。

写这个代码的目的是结合以前所学知识,对知识进行容纳贯穿

注: 内容过多,还未写完,下篇见

--------------------- END ----------------------

「 作者 」 枫叶先生「 更新 」 .6.21「 声明 」 余之才疏学浅,故所撰文疏漏难免,或有谬误或不准确之处,敬请读者批评指正。

如果觉得《「网络编程」第二讲:网络编程socket套接字(三)_ 简单TCP网络通信程序的实现》对你有帮助,请点赞、收藏,并留下你的观点哦!

本内容不代表本网观点和政治立场,如有侵犯你的权益请联系我们处理。
网友评论
网友评论仅供其表达个人看法,并不表明网站立场。