网络编程套接字( TCP )
目录
1、实现一个TCP网络程序(单进程版)
1.1、服务端serverTcp.cc文件
服务端创建套接字
服务端绑定
服务端监听
服务端获取连接
服务端提供服务
服务端main函数命令行参数
服务端serverTcp.cc总代码
1.2、客户端clientTcp.cc文件
客户端main函数命令行参数
客户端创建套接字
客户端的bind、listen、accept问题
客户端连接服务器
客户端发起请求
客户端clinetTcp.cc总代码
1.3、服务器测试
1.4、单执行流服务器的问题
2、多进程版的TCP网络程序
捕捉SIGCHLD信号
让孙子进程提供服务
3、多线程版的TCP网络程序
4、线程池版的TCP网络程序
线程池变形
5、总代码gitee链接
1、实现一个TCP网络程序(单进程版)
1.1、服务端serverTcp.cc文件
我们把服务器封装成一个ServerTcp类,该类里主要有如下几个任务:
- 服务端创建套接字
- 服务端绑定
- 服务端监听
- 服务端获取链接
- 服务端提供服务
- 服务端main函数命令行参数
下面依次演示:
服务端创建套接字
我们把服务器封装成一个ServerTcp类,当我们定义出一个服务器对象后需要马上初始化服务器,而初始化服务器首先要创建套接字。创建套接字的函数叫做socket函数,再回顾下其函数原型:
int socket(int domain, int type, int protocol);
这里TCP服务器在调用socket函数创建套接字时,参数设置如下:
- domain:协议家族选择AF_INET,因为我们要进行的是网络通信。
- type:创建套接字时所需的服务器类型应该是SOCK_STREAM,因为我们编写的是TCP服务器,SOCK_STREAM提供的就是一个有序的、可靠的、全双工的、基于连接的流式服务。注意我UDP是用户数据报服务。
- protocol:协议类型默认设置为0即可。
若socket创建失败,则复用logMessage函数打印相关日志信息,并直接exit退出程序。
class ServerTcp { public: // 构造函数 + 析构函数 public: // 初始化 void init() { // 1、创建socket sock_ = socket(AF_INET, SOCK_STREAM, 0); if (sock_ < 0) { logMessage(FATAL, "socket: %s", strerror(errno)); // 创建失败,打印日志 exit(SOCKET_ERR); } logMessage(DEBUG, "socket: %s, %d", strerror(errno), sock_); } private: int sock_; // socket uint16_t port_; // port string ip_; // ip };
服务端绑定
- 当套接字已经创建成功了,但作为一款服务器来讲,如果只是把套接字创建好了,那我们也只是在系统层面上打开了一个文件,操作系统将来并不知道是要将数据写入到磁盘还是刷到网卡,此时该文件还没有与网络关联起来。所以我们需要调用bind函数进行绑定操作。
绑定的步骤如下:
- 1、绑定网络信息,先填充基本信息到struc sockaddr_in结构体。
- 定义struc sockaddr_in结构体对象local,复用memset函数对local进行初始化。将协议家族、端口号、IP地址等信息填充到该结构体变量当中。注意协议家族这里设定的是PF_INET。
- 服务器的端口号是要发给对方的,在发送到网络之前要复用htons主机转网络函数把端口号port_转成网络序列,才能向外发送。
- ip地址默认是字符串风格点分十进制的,这里复用inet_aton函数将字符串IP转换成整数IP(inet_addr除了做转换,还会自动给我们做主机转网络)。注意若ip地址是空的,那就用INADDR_ANY这个宏,否则再用inet_addr函数。这个宏就是0,因此在设置时不需要进行网络字节序的转换。
- 2、绑定网络信息,上述local临时变量(struc sockaddr_in结构体对象)是在用户栈上开辟的,要将其写入内核中。复用bind函数完成绑定操作。bind成功与否均复用logMessage函数打印相关日志信息。
- 由于bind函数提供的是通用参数类型,因此在传入结构体地址时还需要将struct sockaddr_in*强转为struct sockaddr*类型后再进行传入。
class ServerTcp { public: // 构造函数 + 析构函数 public: // 初始化 void init() { // 1、创建socket // 2、bind绑定 // 2.1、填充服务器信息 struct sockaddr_in local; // 用户栈 memset(&local, 0, sizeof(local)); local.sin_family = PF_INET; local.sin_port = htons(port_); ip_.empty() ? (local.sin_addr.s_addr = INADDR_ANY) : (inet_aton(ip_.c_str(), &local.sin_addr)); // 2.2、将本地socket信息,写入sock_对应的内核区域 if (bind(sock_, (const struct sockaddr *)&local, sizeof(local)) == -1) { logMessage(FATAL, "bind: %s", strerror(errno)); // 绑定失败,打印日志 exit(BIND_ERR); } logMessage(DEBUG, "bind: %s, %d", strerror(errno), sock_); } private: int sock_;// socket uint16_t port_; // port string ip_; // ip };
服务端监听
listen接口说明
- UDP服务器的初始化操作只有两步,第一步就是创建套接字,第二步就是绑定。而TCP服务器是面向连接的,客户端在正式向TCP服务器发送数据之前,需要先与TCP服务器建立连接,然后才能与服务器进行通信。
- 因此TCP服务器需要时刻注意是否有客户端发来连接请求,此时就需要将TCP服务器创建的套接字设置为监听状态。
设置套接字为监听状态的函数叫做listen,该函数的函数原型如下:
int listen(int sockfd, int backlog);
参数说明:
- sockfd:需要设置为监听状态的套接字对应的文件描述符。
- backlog:全连接队列的最大长度。如果有多个客户端同时发来连接请求,此时未被服务器处理的连接就会放入连接队列,该参数代表的就是这个全连接队列的最大长度,一般不要设置太大,设置为5或10即可。
返回值说明:
- 监听成功返回0,监听失败返回-1,同时错误码会被设置。
代码逻辑如下
- TCP是面向连接的,所以要让TCP服务器时刻注意是否有客户端发来连接请求,此时就需要将TCP服务器创建的套接字设置为监听状态。监听失败就打印日志信息,并直接退出。因为监听失败就意味着TCP服务器无法接受客户端发来的连接请求。
class ServerTcp { public: // 构造函数 + 析构函数 public: // 初始化 void init() { // 1、创建socket // 2、bind绑定 // 3、监听socket if (listen(sock_, 5) < 0) { logMessage(FATAL, "listen: %s", strerror(errno)); // 监听失败,打印日志 exit(LISTEN_ERR); } logMessage(DEBUG, "listen: %s, %d", strerror(errno), sock_); } private: int sock_; // socket uint16_t port_; // port string ip_; // ip };
初始化TCP服务器时创建的套接字并不是普通的套接字,而应该叫做监听套接字。为了表明寓意,我们将代码中套接字的名字由sock_改为listensock_。
服务端获取连接
accept接口说明
- TCP服务器初始化后就可以开始运行了,但TCP服务器在与客户端进行网络通信之前,服务器需要先获取到客户端的连接请求。究竟是谁连接我的。
获取连接的函数叫做accept,该函数的函数原型如下:
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
参数说明:
- sockfd:特定的监听套接字,表示从该监听套接字中获取连接。
- addr:对端网络相关的属性信息,包括协议家族、IP地址、端口号等。
- addrlen:调用时传入期望读取的addr结构体的长度,返回时代表实际读取到的addr结构体的长度,这是一个输入输出型参数。
返回值说明:
- 获取连接成功返回接收到的套接字的文件描述符,获取连接失败返回-1,同时错误码会被设置。
调用accept函数获取连接时,是从监听套接字当中获取的。如果accept函数获取连接成功,此时会返回接收到的套接字对应的文件描述符。监听套接字与accept函数返回的套接字的作用:
- 监听套接字:用于获取客户端发来的连接请求。accept函数会不断从监听套接字当中获取新连接。
- accept函数返回的套接字:用于为本次accept获取到的连接提供服务(为用户提供网络服务,主要是进行IO)。监听套接字的任务只是不断获取新连接,而真正为这些连接提供服务的套接字是accept函数返回的套接字,而不是监听套接字。
代码逻辑如下
- 定义struct sockaddr_in的对象peer,定义len为peer的字节数
- 复用accept函数获取连接。若返回值<0说明连接失败,但是TCP服务器不会因为某个连接失败而退出,因此服务端获取连接失败后应该继续获取连接。
- 获取连接成功后,要获取客户端的基本信息,将客户端的IP地址和端口号信息进行输出,需要调用inet_ntoa函数将整数IP转换成字符串IP,调用ntohs函数将端口号由网络序列转换成主机序列。
class ServerTcp { public: // 构造函数 + 析构函数 public: // 初始化 void init() { // 1、创建socket // 2、bind绑定 // 3、监听socket } // 启动服务端 void loop() { while (true) { // 4、获取连接 struct sockaddr_in peer; socklen_t len = sizeof(peer); int serviceSock = accept(listensock_, (struct sockaddr *)&peer, &len); if (serviceSock < 0) { logMessage(WARINING, "accept: %s[%d]", strerror(errno), serviceSock); // 获取连接失败 continue; } // 4.1、获取客户端基本信息 uint16_t peerPort = ntohs(peer.sin_port); string peerIp = inet_ntoa(peer.sin_addr); logMessage(DEBUG, "accept: %s | %s:[%d], socket fd: %d", strerror(errno), peerIp.c_str(), peerPort, serviceSock); } } private: int listensock_;// socket uint16_t port_; // port string ip_; // ip };
服务端接受连接测试
- 这里我们客户端还没有写,但是我们可以先允许服务端,然后在windows下的浏览器上用当前云服务器ip(124.71.25.237)+端口号(8080)进行访问测试
- 浏览器常见的应用层协议是http或https,其底层对应的也是TCP协议,因此浏览器也可以向当前这个TCP服务器发起请求连接。测试如下:
注意:
- 至于这里为什么浏览器一次会向我们的TCP服务器发起两次请求这个问题,这里就不作讨论了,我们只是要证明当前TCP服务器能够正常接收外部的请求连接。
服务端提供服务
read接口说明
- 现在TCP服务器已经能够获取连接请求了,下面当然就是要对获取到的连接进行处理。为了让通信双方都能看到对应的现象,我们这里就实现一个简单的回声TCP服务器,服务端在为客户端提供服务时就简单的将客户端发来的数据进行输出,并且将客户端发来的数据重新发回给客户端即可。当客户端拿到服务端的响应数据后再将该数据进行打印输出,此时就能确保服务端和客户端能够正常通信了。
TCP服务器读取数据的函数叫做read,该函数的函数原型如下:
ssize_t read(int fd, void *buf, size_t count);
参数说明:
- fd:特定的文件描述符,表示从该文件描述符中读取数据。
- buf:数据的存储位置,表示将读取到的数据存储到该位置。
- count:数据的个数,表示从该文件描述符中读取数据的字节数。
返回值说明:
- 如果返回值大于0,则表示本次实际读取到的字节个数。
- 如果返回值等于0,则表示对端已经把连接关闭了。
- 如果返回值小于0,则表示读取时遇到了错误。
read返回值为0表示对端连接关闭。这实际和本地进程间通信中的管道通信是类似的,当使用管道进行通信时,可能会出现如下情况:
- 写端进程不写,读端进程一直读,此时读端进程就会被挂起,因为此时数据没有就绪。
- 读端进程不读,写端进程一直写,此时当管道被写满后写端进程就会被挂起,因为此时空间没有就绪。
- 写端进程将数据写完后将写端关闭,此时当读端进程将管道当中的数据读完后就会读到0。
- 读端进程将读端关闭,此时写端进程就会被操作系统杀掉,因为此时写端进程写入的数据不会被读取。
这里的写端就对应客户端,如果客户端将连接关闭了,那么此时服务端将套接字当中的信息读完后就会读取到0,因此如果服务端调用read函数后得到的返回值为0,此时服务端就不必再为该客户端提供服务了。
write接口说明
- TCP服务器写入数据的函数叫做write,该函数的函数原型如下:
ssize_t write(int fd, const void *buf, size_t count);
参数说明:
- fd:特定的文件描述符,表示将数据写入该文件描述符对应的套接字。
- buf:需要写入的数据。
- count:需要写入数据的字节个数。
返回值说明:
- 写入成功返回实际写入的字节数,写入失败返回-1,同时错误码会被设置。
当服务端调用read函数收到客户端的数据后,就可以再调用write函数将该数据再响应给客户端。
代码逻辑如下
- 注意:服务端读取数据是服务套接字中读取的,而写入数据的时候也是写入进服务套接字的。也就是说这里为客户端提供服务的套接字,既可以读取数据也可以写入数据,这就是TCP全双工的通信的体现。
- 这里我们把服务端提供服务的过程封装成一个transService函数,其内部完成的主要功能是完成大小写转化
- 首先,调用read函数读取客户端发来的数据,这里且假定读取的是字符串。read函数返回值为s。
- 若返回值s > 0,说明读取成功,在内部首先调用strcasecmp函数判断客户端是否需要服务端提供服务,若不需要(quit),则打印日志并退出,若需要,在内部完成大小写转化的功能。转化完成后调用write函数将结果返回给客户端
- 若返回值s = 0或s < 0,此时就应该直接将服务套接字对应的文件描述符关闭。因为文件描述符本质就是数组的下标,因此文件描述符的资源是有限的,如果我们一直占用,那么可用的文件描述符就会越来越少,因此服务完客户端后要及时关闭对应的文件描述符,否则会导致文件描述符泄漏。
class ServerTcp { public: // 构造函数 + 析构函数 public: // 初始化 void init() { // 1、创建socket // 2、bind绑定 // 3、监听socket } // 启动服务端 void loop() { while (true) { // 4、获取连接 // 4.1、获取客户端基本信息 // 5、提供服务,echo ( 小写 -> 大写 ) // 5.0 v0版本 transService(serviceSock, peerIp, peerPort); } } // 大小写转化服务 // TCP && UDP: 支持全双工 void transService(int sock, const string &clientIp, uint16_t clientPort) { assert(socket >= 0); assert(!clientIp.empty()); assert(clientPort >= 1024); char inbuffer[BUFFER_SIZE]; while (true) { ssize_t s = read(sock, inbuffer, sizeof(inbuffer) - 1); // 我们认为读取到的都是字符串 if (s > 0) // 读取成功 { inbuffer[s] = '\0'; // read success if (strcasecmp(inbuffer, "quit") == 0) // strcasecmp是忽略大小写比较的函数 { // 客户端输入退出 logMessage(DEBUG, "client quit -- %s[%d]", clientIp.c_str(), clientPort); break; } logMessage(DEBUG, "trans before: %s[%d]>>> %s", clientIp.c_str(), clientPort, inbuffer); // 可以进行大小写转化了 for (int i = 0; i < s; i++) { if (isalpha(inbuffer[i]) && islower(inbuffer[i])) inbuffer[i] = toupper(inbuffer[i]); } logMessage(DEBUG, "trans after: %s[%d]>>> %s", clientIp.c_str(), clientPort, inbuffer); // 进行写回操作 write(sock, inbuffer, strlen(inbuffer)); } else if (s == 0) // 对方关闭 { logMessage(DEBUG, "client quit -- %s[%d]", clientIp.c_str(), clientPort); break; } else // 读取出错 { logMessage(DEBUG, "%s[%d] - read: %s", clientIp.c_str(), clientPort, strerror(errno)); break; } } // 只要走到这里,一定是client退出了,服务到此结束 close(sock); // 如果一个进程对应的文件fd,打开了没有被归还,则文件描述符泄露 logMessage(DEBUG, "server close %d done", sock); } private: int listensock_; // 监听套接字socket uint16_t port_; // port string ip_; // ip };
服务端main函数命令行参数
将来我们的服务端在启动的时候,在命令行中一定是按照如下格式输入的:
./ServerTcp local_port local_ip
我们需要给main函数加上命令行参数,内部代码逻辑如下:
- 利用命令行参数的形式,若main函数中argc != 2 && argc != 3,则复用提示信息函数Usage,并exit退出进程
- 定义port端口为命令行的第二个参数(下标为1的参数)
- 若argc == 3,则定义ip地址为命令行的第三个参数(下标为2的参数)
- 将端口号和ip地址传入ServerTcp服务器的类里,调用init和start函数
static void Usage(string proc) { cerr << "Usage:\n\t" << proc << "port ip" << endl; cerr << "Example:\n\t" << proc << "8080 127.0.0.1\n" << endl; } // ./ServerTcp local_port local_ip int main(int argc, char *argv[]) { if (argc != 2 && argc != 3) { Usage(argv[0]); exit(USAGE_ERR); } uint16_t port = atoi(argv[1]); string ip; if (argc == 3) ip = argv[2]; ServerTcp svr(port, ip); svr.init(); svr.loop(); return 0; }
服务端serverTcp.cc总代码
ServerTcp类的成员变量如下:
- listensock_
- port_
- ip_
ServerTcp类的成员函数如下:
- ServerTcp构造函数
- ServerTcp析构函数
- init初始化函数
- loop启动服务器函数
总代码如下:
#include "utli.hpp" class ServerTcp { public: ServerTcp(uint16_t port, const string &ip = "") : port_(port), ip_(ip), listensock_(-1) {} ~ServerTcp() {} public: // 初始化 void init() { // 1、创建socket listensock_ = socket(AF_INET, SOCK_STREAM, 0); if (listensock_ < 0) { logMessage(FATAL, "socket: %s", strerror(errno)); // 创建失败,打印日志 exit(SOCKET_ERR); } logMessage(DEBUG, "socket: %s, %d", strerror(errno), listensock_); // 2、bind绑定 // 2.1、填充服务器信息 struct sockaddr_in local; // 用户栈 memset(&local, 0, sizeof(local)); local.sin_family = PF_INET; local.sin_port = htons(port_); ip_.empty() ? (local.sin_addr.s_addr = INADDR_ANY) : (inet_aton(ip_.c_str(), &local.sin_addr)); // 2.2、将本地socket信息,写入listensock_对应的内核区域 if (bind(listensock_, (const struct sockaddr *)&local, sizeof(local)) == -1) { logMessage(FATAL, "bind: %s", strerror(errno)); // 绑定失败,打印日志 exit(BIND_ERR); } logMessage(DEBUG, "bind: %s, %d", strerror(errno), listensock_); // 3、监听socket if (listen(listensock_, 5) < 0) { logMessage(FATAL, "listen: %s", strerror(errno)); // 监听失败,打印日志 exit(LISTEN_ERR); } logMessage(DEBUG, "listen: %s, %d", strerror(errno), listensock_); // 允许别人连接你了 } // 启动服务端 void loop() { while (true) { // 4、获取连接 struct sockaddr_in peer; socklen_t len = sizeof(peer); int serviceSock = accept(listensock_, (struct sockaddr *)&peer, &len); if (serviceSock < 0) { logMessage(WARINING, "accept: %s[%d]", strerror(errno), serviceSock); // 获取连接失败 continue; } // 4.1、获取客户端基本信息 uint16_t peerPort = ntohs(peer.sin_port); string peerIp = inet_ntoa(peer.sin_addr); logMessage(DEBUG, "accept: %s | %s:[%d], socket fd: %d", strerror(errno), peerIp.c_str(), peerPort, serviceSock); // 5、提供服务,echo ( 小写 -> 大写 ) // 5.0 v0版本 —— 单进程 transService(serviceSock, peerIp, peerPort); } } // 大小写转化服务 // TCP && UDP: 支持全双工 void transService(int sock, const string &clientIp, uint16_t clientPort) { assert(socket >= 0); assert(!clientIp.empty()); assert(clientPort >= 1024); char inbuffer[BUFFER_SIZE]; while (true) { ssize_t s = read(sock, inbuffer, sizeof(inbuffer) - 1); // 我们认为读取到的都是字符串 if (s > 0) // 读取成功 { inbuffer[s] = '\0'; // read success if (strcasecmp(inbuffer, "quit") == 0) // strcasecmp是忽略大小写比较的函数 { // 客户端输入退出 logMessage(DEBUG, "client quit -- %s[%d]", clientIp.c_str(), clientPort); break; } logMessage(DEBUG, "trans before: %s[%d]>>> %s", clientIp.c_str(), clientPort, inbuffer); // 可以进行大小写转化了 for (int i = 0; i < s; i++) { if (isalpha(inbuffer[i]) && islower(inbuffer[i])) inbuffer[i] = toupper(inbuffer[i]); } logMessage(DEBUG, "trans after: %s[%d]>>> %s", clientIp.c_str(), clientPort, inbuffer); // 进行写回操作 write(sock, inbuffer, strlen(inbuffer)); } else if (s == 0) // 对方关闭 { logMessage(DEBUG, "client quit -- %s[%d]", clientIp.c_str(), clientPort); break; } else // 读取出错 { logMessage(DEBUG, "%s[%d] - read: %s", clientIp.c_str(), clientPort, strerror(errno)); break; } } // 只要走到这里,一定是client退出了,服务到此结束 close(sock); // 如果一个进程对应的文件fd,打开了没有被归还,则文件描述符泄露 logMessage(DEBUG, "server close %d done", sock); } private: int listensock_; // 监听套接字socket uint16_t port_; // port string ip_; // ip }; static void Usage(string proc) { cerr << "Usage:\n\t" << proc << "port ip" << endl; cerr << "Example:\n\t" << proc << "8080 127.0.0.1\n" << endl; } // ./ServerTcp local_port local_ip int main(int argc, char *argv[]) { if (argc != 2 && argc != 3) { Usage(argv[0]); exit(USAGE_ERR); } uint16_t port = atoi(argv[1]); string ip; if (argc == 3) ip = argv[2]; ServerTcp svr(port, ip); svr.init(); svr.loop(); return 0; }
1.2、客户端clientTcp.cc文件
这里我们不像服务端udpServer.cc一样进行封装成类了。其内部主要框架逻辑如下:
- main函数采用命令行参数
- 客户端创建套接字
- 通讯过程(启动客户端)
下面依次演示
客户端main函数命令行参数
客户端在启动的时候必须要知道服务端的ip和port,才能进行连接服务端。未来的客户端程序一定是这样运行的:
./clientTcp serverIp serverPort
- 如果命令行参数个数argc != 3,复用Usage函数输出相关提示信息,并退出程序
- 定义string类型的serverIp变量保存命令行的第二个参数
- 定义serverPort变量保存命令行中的第三个参数
static void Usage(string proc) { cerr << "Usage:\n\t" << proc << "serverIp serverPort" << endl; cerr << "Example:\n\t" << proc << "127.0.0.1 8080\n" << endl; } // ./clientTcp serverIp serverPort int main(int argc, char* argv[]) { if (argc != 3) { Usage(argv[0]); exit(USAGE_ERR); } string serverIp = argv[1]; uint16_t serverPort = atoi(argv[2]); return 0; }
客户端创建套接字
客户端创建套接字时选择的协议家族也是AF_INET,需要的服务类型也是SOCK_STREAM。与服务端不同的是,客户端在初始化时只需要创建套接字就行了,而不需要进行绑定操作。
int main() { ... // 1、创建socket int sock = socket(AF_INET, SOCK_STREAM, 0); if (sock < 0) { cerr << "socket: " << strerror(errno) << endl; exit(SOCKET_ERR); } ... close(sock); return 0; }
客户端的bind、listen、accept问题
客户端需不需要自己进行bind绑定呢?
- 不需要。所谓的“不需要”,指的是:客户端不需要用户自己bind端口信息!因为OS会自动给你绑定。(这个问题和udp的一样)
客户端需不需要自己进行listen监听呢?
- 不需要。监听本来就是等着别人来连你,作为客户端,你是要主动连接别人的,而不是等着服务端自动向你连接的,这属实反客为主了。
- 而服务端需要进行监听是因为服务端需要通过监听来获取新连接,但是不会有人主动连接客户端,因此客户端是不需要进行监听操作的。
客户端需不需要自己进行accept获取呢?
- 不需要,因为都没有listen,都没有人来连你,当然不用accpet
客户端连接服务器
connect接口说明
- 客户端创建完套接字后需要向服务器发送链接请求。发起连接请求的函数叫做connect,该函数的函数原型如下:
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
参数说明:
- sockfd:特定的套接字,表示通过该套接字发起连接请求。
- addr:对端网络相关的属性信息,包括协议家族、IP地址、端口号等。
- addrlen:传入的addr结构体的长度。
返回值说明:
- 连接或绑定成功返回0,连接失败返回-1,同时错误码会被设置。
代码逻辑如下
- 定义struct sockaddr_in类型的结构体指针server,复用memset函数对其清零
- 填写服务器对应的信息,将协议家族、端口号、IP地址等信息填充到该结构体变量当中。
- 注意要复用htons主机转网络函数把端口号转成网络序列,才能向外发送。
- 注意要复用inet_aton函数将字符串IP转换成整数IP
- 复用connect函数向服务器发送连接请求
int main(int argc, char *argv[]) { // 1、创建socket // 2、connect, 向服务器发起连接请求 // 2.1、先填充需要连接的远端主机的基本信息 struct sockaddr_in server; memset(&server, 0, sizeof(server)); server.sin_family = AF_INET; server.sin_port = htons(serverPort); inet_aton(serverIp.c_str(), &server.sin_addr); // 2.2、发起请求,connect 回自动帮我们进行bind if (connect(sock, (const struct sockaddr *)&server, sizeof(server)) != 0) { cerr << "connect: " << strerror(errno) << endl; exit(CONN_ERR); } cout << "info: connect success: " << sock << endl; return 0; }
客户端发起请求
- 由于我们实现的是一个简单的回声服务器,因此当客户端连接到服务端后,客户端就可以向服务端发送数据了,这里我们可以让客户端将用户输入的数据发送给服务端,发送时调用write函数向套接字当中写入数据即可。
- 当客户端将数据发送给服务端后,由于服务端读取到数据后还会进行回显,因此客户端在发送数据后还需要调用read函数读取服务端的响应数据,然后将该响应数据进行打印,以确定双方通信无误。
int main(int argc, char *argv[]) { // 1、创建socket // 2、connect, 向服务器发起连接请求 // 2.1、先填充需要连接的远端主机的基本信息 struct sockaddr_in server; memset(&server, 0, sizeof(server)); server.sin_family = AF_INET; server.sin_port = htons(serverPort); inet_aton(serverIp.c_str(), &server.sin_addr); // 2.2、发起请求,connect 回自动帮我们进行bind if (connect(sock, (const struct sockaddr *)&server, sizeof(server)) != 0) { cerr << "connect: " << strerror(errno) << endl; exit(CONN_ERR); } cout << "info: connect success: " << sock << endl; string message; while (!quit) { message.clear(); cout << "请输入你的消息>>> "; getline(cin, message); if (strcasecmp(message.c_str(), "quit") == 0) quit = true; ssize_t s = write(sock, message.c_str(), message.size()); if (s > 0) { message.resize(1024); ssize_t s = read(sock, (char*)(message.c_str()), 1024); if (s > 0) message[s] = 0; cout << "Server Echo>>> " << message << endl; } else if (s <= 0) { break; } } return 0; }
客户端clinetTcp.cc总代码
clientTcp.cc文件的内部主要框架逻辑如下:
- main函数使用命令行参数:
- 客户端创建套接字
- 连接过程
总代码如下:
#include "utli.hpp" volatile bool quit = false; static void Usage(string proc) { cerr << "Usage:\n\t" << proc << "serverIp serverPort" << endl; cerr << "Example:\n\t" << proc << "127.0.0.1 8080\n" << endl; } // ./clientTcp serverIp serverPort int main(int argc, char *argv[]) { if (argc != 3) { Usage(argv[0]); exit(USAGE_ERR); } string serverIp = argv[1]; uint16_t serverPort = atoi(argv[2]); // 1、创建socket int sock = socket(AF_INET, SOCK_STREAM, 0); if (sock < 0) { cerr << "socket: " << strerror(errno) << endl; exit(SOCKET_ERR); } // 2、connect, 向服务器发起连接请求 // 2.1、先填充需要连接的远端主机的基本信息 struct sockaddr_in server; memset(&server, 0, sizeof(server)); server.sin_family = AF_INET; server.sin_port = htons(serverPort); inet_aton(serverIp.c_str(), &server.sin_addr); // 2.2、发起请求,connect 回自动帮我们进行bind if (connect(sock, (const struct sockaddr *)&server, sizeof(server)) != 0) { cerr << "connect: " << strerror(errno) << endl; exit(CONN_ERR); } cout << "info: connect success: " << sock << endl; string message; while (!quit) { message.clear(); cout << "请输入你的消息>>> "; getline(cin, message); if (strcasecmp(message.c_str(), "quit") == 0) quit = true; ssize_t s = write(sock, message.c_str(), message.size()); if (s > 0) { message.resize(1024); ssize_t s = read(sock, (char*)(message.c_str()), 1024); if (s > 0) message[s] = 0; cout << "Server Echo>>> " << message << endl; } else if (s <= 0) { break; } } close(sock); return 0; }
1.3、服务器测试
现在服务端和客户端均已写好,先运行服务端,再运行客户端。我们使用如下指令辅助我们观察现象:
[xzy@ecs-333953 tcp]$ sudo netstat -ntp | grep -E 'serverTcp|clientTcp'
如上我服务器的端口是8081,它已经和端口43914的客户端相互建立起了连接:
现在就可以让客户端向服务端发送消息了,当客户端向服务端发送消息后,服务端可以通过打印的IP地址和端口号识别出对应的客户端,而客户端也可以通过服务端响应回来的消息来判断服务端是否收到了自己发送的消息。
当我客户端发送quit退出动作时,服务端识别后,确认客户端退出,并关闭对应的socket。如果我强制ctrl -c退出客户端,OS会自动帮我们关掉对应的文件描述符,此时服务端也就知道客户端退出了,进而会终止对客户端的服务。
1.4、单执行流服务器的问题
当我们仅用一个客户端连接服务器时,这一个客户端能够正常享受到服务端的服务:
但当此客户端1正常享受服务端的服务时,我们让另一个客户端2也连接此服务器, 此时发现两个客户端都是可以正常连接的,但是客户端2发给服务端的消息并没有在服务端进行打印,服务端也没有将该数据回显给客户端2。相反我客户端1和服务端是能够正常通信的:
但是当客户端1退出后,服务端才将客户端2发来的数据进行打印,并回显到客户端2上:
单进程的服务器
- 通过实验现象可以看到,这服务端只有服务完一个客户端后才会服务另一个客户端。因为我们目前所写的是一个单执行流版的服务器,这个服务器一次只能为一个客户端提供服务,一旦进入transService函数,主执行流就无法进行向后执行,只能提供完毕服务之后才能进行accept。
- 当服务端调用accept函数获取到连接后就给该客户端提供服务,但在服务端提供服务期间可能会有其他客户端发起连接请求,但由于当前服务器是单执行流的,只能服务完当前客户端后才能继续服务下一个客户端。
解决办法
- 单执行流的服务器一次只能给一个客户端提供服务,此时服务器的资源并没有得到充分利用,因此服务器一般是不会写成单执行流的。要解决这个问题就需要将服务器改为多执行流的,此时就要引入多进程或多线程。
2、多进程版的TCP网络程序
- 当服务端调用accept函数获取到新连接后不是由当前执行流为该连接提供服务,而是当前执行流调用fork函数创建子进程,然后让子进程为父进程获取到的连接提供服务。
- 由于父子进程是两个不同的执行流,当父进程调用fork创建出子进程后,父进程就可以继续从监听套接字当中获取新连接,而不用关心获取上来的连接是否服务完毕
- 父进程创建的子进程会继承父进程的套接字文件,此时子进程就能够对特定的套接字文件进行读写操作,进而完成对对应客户端的服务。
等待子进程问题
当父进程创建出子进程后,父进程是需要等待子进程退出的,否则子进程会变成僵尸进程,进而造成内存泄漏。因此服务端创建子进程后需要调用wait或waitpid函数对子进程进行等待。
阻塞式等待与非阻塞式等待:
- 如果服务端采用阻塞的方式等待子进程,那么服务端还是需要等待服务完当前客户端,才能继续获取下一个连接请求,此时服务端仍然是以一种串行的方式为客户端提供服务。
- 如果服务端采用非阻塞的方式等待子进程,虽然在子进程为客户端提供服务期间服务端可以继续获取新连接,但此时服务端就需要将所有子进程的PID保存下来,并且需要不断花费时间检测子进程是否退出。
总之,服务端要等待子进程退出,无论采用阻塞式等待还是非阻塞式等待,都不尽人意。此时我们可以考虑让服务端不等待子进程退出。不等待子进程退出的方式如下:
- 捕捉SIGCHLD信号,将其处理动作设置为忽略。
- 让父进程创建子进程,子进程再创建孙子进程,最后让孙子进程为客户端提供服务。
捕捉SIGCHLD信号
实际当子进程退出时会给父进程发送SIGCHLD信号,如果父进程将SIGCHLD信号进行捕捉,并将该信号的处理动作设置为忽略,此时父进程就只需专心处理自己的工作,不必关心子进程了。
class ServerTcp { public: // 构造 + 析构 public: // 初始化 // 启动服务端 void loop() { signal(SIGCHLD, SIG_IGN); // 忽略SIGCHLD信号 while (true) { // 4、获取连接 struct sockaddr_in peer; socklen_t len = sizeof(peer); int serviceSock = accept(listensock_, (struct sockaddr *)&peer, &len); if (serviceSock < 0) { logMessage(WARINING, "accept: %s[%d]", strerror(errno), serviceSock); // 获取连接失败 continue; } // 4.1、获取客户端基本信息 uint16_t peerPort = ntohs(peer.sin_port); string peerIp = inet_ntoa(peer.sin_addr); logMessage(DEBUG, "accept: %s | %s:[%d], socket fd: %d", strerror(errno), peerIp.c_str(), peerPort, serviceSock); // 5、提供服务,echo ( 小写 -> 大写 ) // 5.0 v0版本 —— 单进程 // transService(serviceSock, peerIp, peerPort); // 5.1 v1版本 —— 多进程 pid_t id = fork(); assert(id != -1); if (id == 0) { close(listensock_); // 建议关掉 // 子进程 transService(serviceSock, peerIp, peerPort); exit(0); // 子进程退出进入僵尸 } // 父进程 close(serviceSock); // 一定要做 } } private: int listensock_; // 监听套接字socket uint16_t port_; // port string ip_; // ip };
测试结果:
我们使用如下的监控脚本辅助我们观察现象:
[xzy@ecs-333953 tcp]$ ps -axj | head -1 && ps axj | grep serverTcp
- 当我们让客户端1连接服务器后,服务器进程会调用fork函数创建出一个子进程并提供服务;当我们让客户端2连接服务器后,服务器进程同样会调用fork函数创建出一个子进程并提供服务。所以我们会看到3个进程在运行的状态:
- 如下我们还应该看到客户端1和客户端2各自向服务端发送信息,且都能正常收到服务端的回复。
现在我们让客户端一个一个退出,并用如下的监控脚本观察进程数量的变化:
[xzy@ecs-333953 tcp]$ while :; do ps -axj | head -1 && ps axj | grep serverTcp ; sleep 1 ;done
当客户端一个一个推出后,服务端为之提供的子进程也会相机退出,单无论如何服务端都至少会有一个服务进程,此进程的任务就是不断获取新连接。
让孙子进程提供服务
我们可以让服务端冲断爷爷进程,服务端创建的子进程(爸爸进程)继续fork创建子进程(孙子进程),让孙子进程为客户端提供服务,过程如下:
- 爷爷进程:在服务端调用accept函数获取客户端连接请求的进程。
- 爸爸进程:由爷爷进程调用fork函数创建出来的进程。
- 孙子进程:由爸爸进程调用fork函数创建出来的进程,该进程调用Service函数为客户端提供服务。
不需要等待孙子进程退出
- 我们让爸爸进程创建完孙子进程后立刻退出,此时服务进程(爷爷进程)调用wait/waitpid函数等待爸爸进程就能立刻等待成功,此后服务进程就能继续调用accept函数获取其他客户端的连接请求。
- 而由于爸爸进程创建完孙子进程后就立刻退出了,因此实际为客户端提供服务的孙子进程就变成了孤儿进程,该进程就会被系统领养,当孙子进程为客户端提供完服务退出后系统会回收孙子进程,所以服务进程(爷爷进程)是不需要等待孙子进程退出的。
关闭对应的文件描述符
- 对于服务进程来说,当它调用fork函数后就必须将从accept函数获取的文件描述符关掉。因为服务进程会不断调用accept函数获取新的文件描述符(服务套接字),如果服务进程不及时关掉不用的文件描述符,最终服务进程中可用的文件描述符就会越来越少。
- 而对于爸爸进程和孙子进程来说,还是建议关闭从服务进程继承下来的监听套接字。实际就算它们不关闭监听套接字,最终也只会导致这一个文件描述符泄漏,但一般还是建议关上。因为孙子进程在提供服务时可能会对监听套接字进行某种误操作,此时就会对监听套接字当中的数据造成影响。
代码如下:
class ServerTcp { public: // 构造 + 析构 public: // 初始化 // 启动服务端 void loop() { signal(SIGCHLD, SIG_IGN); // 忽略SIGCHLD信号 while (true) { // 4、获取连接 struct sockaddr_in peer; socklen_t len = sizeof(peer); int serviceSock = accept(listensock_, (struct sockaddr *)&peer, &len); if (serviceSock < 0) { logMessage(WARINING, "accept: %s[%d]", strerror(errno), serviceSock); // 获取连接失败 continue; } // 4.1、获取客户端基本信息 uint16_t peerPort = ntohs(peer.sin_port); string peerIp = inet_ntoa(peer.sin_addr); logMessage(DEBUG, "accept: %s | %s:[%d], socket fd: %d", strerror(errno), peerIp.c_str(), peerPort, serviceSock); // 5、提供服务,echo ( 小写 -> 大写 ) // 5.0 v0版本 —— 单进程 // transService(serviceSock, peerIp, peerPort); // 5.1 v1版本 —— 多进程 ———— 捕捉SIGCHLD信号 // 5.1 v1.1版本 —— 多进程 ———— 让孙子进程提供服务 // 爷爷进程 pid_t id = fork(); if (id == 0) { // 爸爸进程 close(listensock_); // 建议关掉 if (fork() > 0) // 又进行了一次fork,让爸爸进程直接终止 exit(0); // 孙子进程 ———— 没有爸爸 ———— 孤儿进程 ———— 被系统领养 ———— 回收问题就交给了系统来回收 transService(serviceSock, peerIp, peerPort); exit(0); } close(serviceSock); // 一定要做 // 爸爸进程直接终止,立马得到退出码,释放僵尸状态 pid_t ret = waitpid(id, nullptr, 0); // 就用阻塞式 assert(ret > 0); (void)ret; } } private: int listensock_; // 监听套接字socket uint16_t port_; // port string ip_; // ip };
测试结果:
3、多线程版的TCP网络程序
- 创建进程的成本是很高的,创建进程时需要创建该进程对应的进程控制块(task_struct)、进程地址空间(mm_struct)、页表等数据结构。而创建线程的成本比创建进程的成本会小得多,因为线程本质是在进程地址空间内运行,创建出来的线程会共享该进程的大部分资源,因此在实现多执行流的服务器时最好采用多线程进行实现。
当服务进程调用accept函数获取到一个新连接后,就可以直接创建一个线程,让该线程为对应客户端提供服务。
- 当然,主线程(服务进程)创建出新线程后,也是需要等待新线程退出的,否则也会造成类似于僵尸进程这样的问题。但对于线程来说,如果不想让主线程等待新线程退出,可以让创建出来的新线程调用pthread_detach函数进行线程分离,当这个线程退出时系统会自动回收该线程所对应的资源。此时主线程(服务进程)就可以继续调用accept函数获取新连接,而让新线程去服务对应的客户端。
文件描述符关闭的问题:
由于此时所有线程看到的都是同一张文件描述符表,因此当某个线程要对这张文件描述符表做某种操作时,不仅要考虑当前线程,还要考虑其他线程。
- 对于主线程accept上来的文件描述符,主线程不能对其进行关闭操作,该文件描述符的关闭操作应该又新线程来执行。因为是新线程为客户端提供服务的,只有当新线程为客户端提供的服务结束后才能将该文件描述符关闭。
- 对于监听套接字,虽然创建出来的新线程不必关心监听套接字,但新线程不能将监听套接字对应的文件描述符关闭,否则主线程就无法从监听套接字当中获取新连接了。
代码逻辑如下:
- 我们使用pthread_create创建线程,让新线程内部执行为客户端提供服务transService的操作。所以我们需要在线程执行函数threadRoutine里传入客户端ip,port,sock。
- 为了能够让线程执行函数threadRoutine获得ip,port,sock这三个参数,我们在pthread_create的最后一个参数传入一个ThreadData结构体,该结构体内部包含了这三个参数
- 注意我线程函数是在ServerTcp类内部的成员函数,而成员函数有默认的this指针,为了避免pthread_create传参出错,我们需要给线程执行函数threadRoutine设置为static静态成员函数。
- 一旦设置了静态函数,也就意味着此线程执行函数内部无法直接访问ServerTcp类的提供服务transService函数。为了避免这一现象的产生,我们对ThreadData结构体内部多定义一个this_变量,将来在ServerTcp类创建ThreadData结构体指针时,给最后一个参数传入ServerTcp类的this指针。这样我将来在线程执行函数内部就可以通过此ThreadData结构体指针访问this_成员变量,再通过this_成员变量访问ServerTcp类的成员函数transService。即可完成线程为对应客户端提供服务。
class ServerTcp; // 声明一下 class ThreadData { public: ThreadData(uint16_t port, string ip, int sock, ServerTcp *ts) : clientport_(port), clientip_(ip), sock_(sock), this_(ts) { } public: uint16_t clientport_; string clientip_; int sock_; ServerTcp *this_; }; class ServerTcp { public: // 构造 + 析构 public: // 初始化 // 线程执行函数 static void *threadRoutine(void *args) { pthread_detach(pthread_self()); // 设置线程分离 ThreadData *td = static_cast<ThreadData *>(args); td->this_->transService(td->sock_, td->clientip_, td->clientport_); delete td; return nullptr; } // 启动服务端 void loop() { signal(SIGCHLD, SIG_IGN); // 忽略SIGCHLD信号 while (true) { // 4、获取连接 struct sockaddr_in peer; socklen_t len = sizeof(peer); int serviceSock = accept(listensock_, (struct sockaddr *)&peer, &len); if (serviceSock < 0) { logMessage(WARINING, "accept: %s[%d]", strerror(errno), serviceSock); // 获取连接失败 continue; } // 4.1、获取客户端基本信息 uint16_t peerPort = ntohs(peer.sin_port); string peerIp = inet_ntoa(peer.sin_addr); logMessage(DEBUG, "accept: %s | %s:[%d], socket fd: %d", strerror(errno), peerIp.c_str(), peerPort, serviceSock); // 5、提供服务,echo ( 小写 -> 大写 ) // 5.0 v0版本 —— 单进程 // 5.1 v1版本 —— 多进程 // 5.1 v1.1版本 —— 多进程 // 5.2 v2版本 —— 多线程 // 这里不需要古纳比文件描述符,因为多线程是会共享文件描述符表的 ThreadData *td = new ThreadData(peerPort, peerIp, serviceSock, this); pthread_t tid; pthread_create(&tid, nullptr, threadRoutine, (void *)td); } } private: int listensock_; // 监听套接字socket uint16_t port_; // port string ip_; // ip };
测试结果:
我们使用如下监控脚本辅助我们观察现象:
[xzy@ecs-333953 tcp]$ while :; do ps -aL | head -1 && ps -aL | grep serverTcp ; sleep 1 ;done
- 上述四个客户端提供服务的也是两个不同的执行流,因此这四个客户端可以同时享受服务端提供的服务,它们发送给服务端的消息也都能够在服务端进行打印,并且这四个客户端也都能够收到服务端的回显数据。
- 当客户端一个个退出后,为其提供服务的新线程也就会相继退出,最终就只剩下最初的主线程仍在等待新连接的到来。
4、线程池版的TCP网络程序
当前多线程版本存在的问题:
- 每当有新连接到来时,服务端的主线程都会重新为该客户端创建为其提供服务的新线程,而当服务结束后又会将该新线程销毁。这样做不仅麻烦,而且效率低下,每当连接到来的时候服务端才创建对应提供服务的线程。
- 如果有大量的客户端连接请求,此时服务端要为每一个客户端创建对应的服务线程。计算机当中的线程越多,CPU的压力就越大,因为CPU要不断在这些线程之间来回切换,此时CPU在调度线程的时候,线程和线程之间切换的成本就会变得很高。此外,一旦线程太多,每一个线程再次被调度的周期就变长了,而线程是为客户端提供服务的,线程被调度的周期变长,客户端也迟迟得不到应答。
解决办法:
- 可以在服务端预先创建一批线程,当有客户端请求连接时就让这些线程为客户端提供服务,此时客户端一来就有线程为其提供服务,而不是当客户端来了才创建对应的服务线程。
- 当某个线程为客户端提供完服务后,不要让该线程退出,而是让该线程继续为下一个客户端提供服务,如果当前没有客户端连接请求,则可以让该线程先进入休眠状态,当有客户端连接到来时再将该线程唤醒。
- 服务端创建的这一批线程的数量不能太多,此时CPU的压力也就不会太大。此外,如果有客户端连接到来,但此时这一批线程都在给其他客户端提供服务,这时服务端不应该再创建线程,而应该让这个新来的连接请求在全连接队列进行排队,等服务端这一批线程中有空闲线程后,再将该连接请求获取上来并为其提供服务。
线程池:
- 我们需要在服务端引入线程池,因为线程池的存在就是为了避免处理短时间任务时创建与销毁线程的代价,此外,线程池还能够保证内核充分利用,防止过分调度。
- 其中在线程池里面有一个任务队列,当有新的任务到来的时候,就可以将任务Push到线程池当中,在线程池当中我们默认创建了5个线程,这些线程不断检测任务队列当中是否有任务,如果有任务就拿出任务,然后调用该任务对应的Run函数对该任务进行处理,如果线程池当中没有任务那么当前线程就会进入休眠状态。
- 我先前已经介绍并实现了线程池,这里就直接将线程池的代码接入到当前的TCP服务器,因此下面只会讲解线程池接入的方法,如果对线程池的实现有疑问的可以去阅读那篇博客。
我们从先前写的线程池取出我们需要的内容放到此tcp目录下:
代码逻辑如下:
现在服务端引入了线程池,因此在服务类当中需要新增一个指向线程池的指针成员:
- 当实例化服务器对象时,先将这个线程池指针先初始化为空。
- 当服务器初始化完毕后,再实际构造这个线程池对象,在构造线程池对象时可以指定线程池当中线程的个数,也可以不指定,此时默认线程的个数为5。
- 在启动服务器之前对线程池进行初始化,此时就会将线程池当中的若干线程创建出来,而这些线程创建出来后就会不断检测任务队列,从任务队列当中拿出任务进行处理。
现在当服务进程调用accept函数获取到一个连接请求后,就会根据该客户端的套接字、IP地址以及端口号构建出一个任务,然后调用线程池提供的Push接口将该任务塞入任务队列。
class ServerTcp { public: ServerTcp(uint16_t port, const string &ip = "") : port_(port), ip_(ip), listensock_(-1), tp_(nullptr) { } ~ServerTcp() { } public: // 初始化 void init() { // 1、创建socket listensock_ = socket(AF_INET, SOCK_STREAM, 0); if (listensock_ < 0) { logMessage(FATAL, "socket: %s", strerror(errno)); // 创建失败,打印日志 exit(SOCKET_ERR); } logMessage(DEBUG, "socket: %s, %d", strerror(errno), listensock_); // 2、bind绑定 // 2.1、填充服务器信息 struct sockaddr_in local; // 用户栈 memset(&local, 0, sizeof(local)); local.sin_family = PF_INET; local.sin_port = htons(port_); ip_.empty() ? (local.sin_addr.s_addr = INADDR_ANY) : (inet_aton(ip_.c_str(), &local.sin_addr)); // 2.2、将本地socket信息,写入listensock_对应的内核区域 if (bind(listensock_, (const struct sockaddr *)&local, sizeof(local)) == -1) { logMessage(FATAL, "bind: %s", strerror(errno)); // 绑定失败,打印日志 exit(BIND_ERR); } logMessage(DEBUG, "bind: %s, %d", strerror(errno), listensock_); // 3、监听socket if (listen(listensock_, 5) < 0) { logMessage(FATAL, "listen: %s", strerror(errno)); // 监听失败,打印日志 exit(LISTEN_ERR); } logMessage(DEBUG, "listen: %s, %d", strerror(errno), listensock_); // 允许别人连接你了 // 4、加载线程池 tp_ = ThreadPool<Task>::getInstance(); } // 启动服务端 void loop() { // 启动线程池 tp_->start(); logMessage(DEBUG, "thread pool start success, thread num: %d", tp_->threadNum()); signal(SIGCHLD, SIG_IGN); // 忽略SIGCHLD信号 while (true) { // 4、获取连接 struct sockaddr_in peer; socklen_t len = sizeof(peer); int serviceSock = accept(listensock_, (struct sockaddr *)&peer, &len); if (serviceSock < 0) { logMessage(WARINING, "accept: %s[%d]", strerror(errno), serviceSock); // 获取连接失败 continue; } // 4.1、获取客户端基本信息 uint16_t peerPort = ntohs(peer.sin_port); string peerIp = inet_ntoa(peer.sin_addr); logMessage(DEBUG, "accept: %s | %s:[%d], socket fd: %d", strerror(errno), peerIp.c_str(), peerPort, serviceSock); // 5、提供服务,echo ( 小写 -> 大写 ) // 5.0 v0版本 —— 单进程 // 5.1 v1版本 —— 多进程 // 5.1 v1.1版本 —— 多进程 // 5.2 v2版本 —— 多线程 // 5.3 v3版本 —— 线程池 // 5.3.1 构建任务 // 5.3 v3.1 // Task t(serviceSock, peerIp, peerPort, std::bind(&ServerTcp::transService, this, std::placeholders::_1, std::placeholders::_2, std::placeholders::_3)); // tp_->push(t); // 5.3 v3.2 Task t(serviceSock, peerIp, peerPort, transService); tp_->push(t); } } private: int listensock_; // 监听套接字socket uint16_t port_; // port string ip_; // ip // 引入线程池 ThreadPool<Task> *tp_; };
设计任务类
- 该任务类当中需要包含客户端对应的套接字、IP地址、端口号,表示该任务是为哪一个客户端提供服务,对应操作的套接字是哪一个。
#pragma once #include <iostream> #include <string> #include <functional> #include <pthread.h> #include "log.hpp" class Task { public: using callBack_t = std::function<void(int, std::string, uint16_t)>; // 等价于typedef std::function<void (int, std::string, uint16_t)> callBack_t; private: int sock_; // 给用户提供IO服务的sock uint16_t port_; // client port std::string ip_; // client ip callBack_t func_; // 回调方法 public: Task() : sock_(-1), port_(-1) { } Task(int sock, std::string ip, uint16_t port, callBack_t func) : sock_(sock), ip_(ip), port_(port), func_(func) { } void operator()() { logMessage(DEBUG, "线程ID[%p]处理%s:%d的请求 开始啦...", pthread_self(), ip_.c_str(), port_); func_(sock_, ip_, port_); logMessage(DEBUG, "线程ID[%p]处理%s:%d的请求 结束啦...", pthread_self(), ip_.c_str(), port_); } ~Task() { } };
代码测试:
我们使用如下监控脚本辅助我们观察现象:
[xzy@ecs-333953 tcp]$ while :; do ps -aL | head -1 && ps -aL | grep serverTcp ; sleep 1 ;done
- 当客户端连接服务器后,服务端的主线程就会获取该客户端的连接请求,并将其封装为一个任务对象后塞入任务队列,此时线程池中的5个线程就会有一个线程从任务队列当中获取到该任务,并执行该任务的处理函数为客户端提供服务。
- 当第二个客户端发起连接请求时,服务端也会将其封装为一个任务类塞到任务队列,然后线程池当中的线程再从任务队列当中获取到该任务进行处理,此时也是不同的执行流为这两个客户端提供的服务,因此这两个客户端也是能够同时享受服务的。
- 与之前不同的是,无论现在有多少客户端发来请求,在服务端都只会有线程池当中的5个线程为之提供服务,线程池当中的线程个数不会随着客户端连接的增多而增多,这些线程也不会因为客户端的退出而退出。
线程池变形
注意:
- 我们设置了对应的任务是死循环,那么线程池提供服务,就显得不太合适。一般我们给线程池抛入的任务都是短任务,现在对代码进行修改。
我们更新线程池的容量为15个。先来看如下这个函数(popen):
#include <stdio.h> FILE *popen(const char *command, const char *type); int pclose(FILE *stream);
popen函数介绍
- 作用:创建一个连接到另一个进程的管道,然后读其输出或向其输入端发送数据。
- 原理:创建一个管道,fork一个子进程,关闭未使用的管道端(读端或者写端),执行一个shell运行命令,然后等待命令终止。
参数说明:
- commmand:是一个指向以 NULL 结束的 shell 命令字符串的指针。这行命令将被传到 bin/sh 并使用 -c 标志,shell 将执行这个命令。
- type:只能是读和写的一种,如果是 “r” 则文件指针连接到command的标准输出,则返回的文件指针是可读的;如果是 “w” 则文件指针连接到command的标准输入,则返回的文件指针是可写的。
- stream:popen返回的文件指针。
代码思想:
- 我们下面要进行的操作就是让线程服务客户端,更换一个服务。先前的服务是进行大小写转化transService,现在来更换一个execCommand。我们只需要改变提供服务的接口即可,代码主逻辑不用动,完成了代码解耦。
代码逻辑:
- 定义command数组,复用read函数把客户端读到的数据输入到此command数组里,将command当成字符串
- 复用popen函数,以只读的方式将数据输出到文件指针fp中。
- 复用fgets函数将fp文件的内容读取到定义的line数组里,并复用write函数将line数组里的内容全部写回到sock里
void execCommand(int sock, const string &clientIp, uint16_t clientPort) { assert(socket >= 0); assert(!clientIp.empty()); assert(clientPort >= 1024); char command[BUFFER_SIZE]; while (true) { ssize_t s = read(sock, command, sizeof(command) - 1); // 我们认为读取到的都是字符串 if (s > 0) // 读取成功 { command[s] = '\0'; // 当成字符串 logMessage(DEBUG, "[%s:%d] exec [%s]", clientIp.c_str(), clientPort, command); // 考虑安全 string safe = command; if (string::npos != (safe.find("rm")) || string::npos != safe.find("unlink")) { break; } FILE *fp = popen(command, "r"); if (fp == nullptr) { logMessage(WARINING, "exec %s failed, because: %s", command, strerror(errno)); break; } char line[1024]; while (fgets(line, sizeof(line) - 1, fp) != nullptr) { write(sock, line, strlen(line)); } // dup2(sock, fp->_fileno); // 把本来应该显示到fp文件的重定向到网络sock里 // fflush(fp); // 把数据刷到对端 pclose(fp); logMessage(DEBUG, "[%s:%d] exec [%s] ... done", clientIp.c_str(), clientPort, command); } else if (s == 0) // 对方关闭 { logMessage(DEBUG, "client quit -- %s[%d]", clientIp.c_str(), clientPort); break; } else // 读取出错 { logMessage(DEBUG, "%s[%d] - read: %s", clientIp.c_str(), clientPort, strerror(errno)); break; } } // 只要走到这里,一定是client退出了,服务到此结束 close(sock); // 如果一个进程对应的文件fd,打开了没有被归还,则文件描述符泄露 logMessage(DEBUG, "server close %d done", sock); } class ServerTcp { public: // 构造 + 析构 public: // 初始化 void init() {} // 启动服务端 void loop() { // 启动线程池 tp_->start(); logMessage(DEBUG, "thread pool start success, thread num: %d", tp_->threadNum()); signal(SIGCHLD, SIG_IGN); // 忽略SIGCHLD信号 while (true) { // 4、获取连接 struct sockaddr_in peer; socklen_t len = sizeof(peer); int serviceSock = accept(listensock_, (struct sockaddr *)&peer, &len); if (serviceSock < 0) { logMessage(WARINING, "accept: %s[%d]", strerror(errno), serviceSock); // 获取连接失败 continue; } // 4.1、获取客户端基本信息 uint16_t peerPort = ntohs(peer.sin_port); string peerIp = inet_ntoa(peer.sin_addr); logMessage(DEBUG, "accept: %s | %s:[%d], socket fd: %d", strerror(errno), peerIp.c_str(), peerPort, serviceSock); // 5、提供服务,echo ( 小写 -> 大写 ) // 5.0 v0版本 —— 单进程 // transService(serviceSock, peerIp, peerPort); // 5.1 v1版本 —— 多进程 // 5.1 v1.1版本 —— 多进程 // 5.2 v2版本 —— 多线程 // 5.3 v3版本 —— 线程池 // 5.3.1 构建任务 // 5.3 v3.1 // 5.3 v3.2 // Task t(serviceSock, peerIp, peerPort, transService); // tp_->push(t); // 5.3 v3.3 Task t(serviceSock, peerIp, peerPort, execCommand); tp_->push(t); } } private: int listensock_; // 监听套接字socket uint16_t port_; // port string ip_; // ip // 引入线程池 ThreadPool<Task> *tp_; };
测试结果:
- 我们看到的现象是当客户端连接服务器后,输入ls,pwd等指令时,服务端提供服务将结果写回到客户端:
5、总代码gitee链接
本篇博文所有设计的代码链接如下:
- gitee传送门:TCP套接字源码