我们将TCP服务器封装成一个类,当我们定义出一个服务器对象后需要马上对服务器进行初始化,而初始化TCP服务器要做的第一件事就是创建套接字。
TCP服务器在调用socket函数创建套接字时,参数设置如下:
class TcpServer
{
public:
void InitServer()
{
_sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (_sockfd < 0)
{
std::cerr << "socket error" << std::endl;
exit(2);
}
}
~TcpServer()
{
if (_sockfd >= 0)
close(_sockfd);
}
private:
int _sockfd;
};
如果创建套接字后获得的文件描述符是小于0的,说明套接字创建失败,此时也就没必要进行后续操作了,直接终止程序即可。
说明一下:实际TCP服务器创建套接字的做法与UDP服务器是一样的,只不过TCP服务器需要的是流式服务,而UDP服务器需要的是用户数据报服务。
套接字创建完毕之后我们实际只是在系统层面上打开了一个文件,该文件还没有与网络关联起来,因此创建完套接字之后我们还需要用bind函数进行绑定。
由于TCP服务器初始化时需要服务器的端口号,因此在服务器类中需要引入端口号,当实例化服务器对象时就需要传入一个端口号。而由于我当前使用的是云服务器,因此在绑定TCP服务器的IP地址时不能绑定公网IP地址,直接绑定INADDR_ANY即可,因此我这里没有在服务器类当中引入IP地址。
void InitServer()
{
_sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (_sockfd < 0)
{
std::cerr << "socket error" << std::endl;
exit(2);
}
// 绑定
struct sockaddr_in local;
memset(&local, 0, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(_port);
local.sin_addr.s_addr = INADDR_ANY;
int ret = bind(_sockfd, (struct sockaddr*)&local, sizeof(local));
if (ret < 0)
{
std::cerr << "bind error" << std::endl;
exit(3);
}
}
UDP服务器的初始化操作只有两步,第一步是创建套接字,第二步是绑定。而TCP服务器是面向连接的,客户端在正式向TCP服务器发送数据之前,需要先与TCP服务器建立连接,然后才能与服务器进行通信。
因此TCP服务器需要时刻注意是否有客户端发来连接请求,此时就需要将TCP服务器创建的套接字设置为监听状态。
listen函数
返回值说明:
服务器监听
TCP服务器在创建完套接字和绑定之后,需要再进一步将套接字设置为监听状态,监听是否有新的连接到来。如果监听失败也没必要进行后续操作了,因为监听失败也就意味着TCP服务器无法接收客户端发来的连接请求,因此监听失败我们直接终止程序即可。
这里在监听时,我们将变量名修改为监听套接字。并且BACKLOG是我自己定义的一个宏,大小为5。
class TcpServer
{
public:
void InitServer()
{
_listen_sock = socket(AF_INET, SOCK_STREAM, 0);
if (_listen_sock < 0)
{
std::cerr << "socket error" << std::endl;
exit(2);
}
// 绑定
struct sockaddr_in local;
memset(&local, 0, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(_port);
local.sin_addr.s_addr = INADDR_ANY;
int ret = bind(_listen_sock, (struct sockaddr*)&local, sizeof(local));
if (ret < 0)
{
std::cerr << "bind error" << std::endl;
exit(3);
}
// 监听
ret = listen(_listen_sock, BACKLOG);
if (ret < 0)
{
std::cerr << "listen error" << std::endl;
exit(4);
}
}
~TcpServer()
{
if (_listen_sock >= 0)
close(_listen_sock);
}
private:
int _listen_sock;
int _port; // 端口号
};
注意:
TCP服务器初始化成功就可以开始运行了,单TCP服务器在与客户端进行通信之前,服务器需要先获取到客户端的连接请求。
accept函数
获取连接的函数叫做accept,该函数的原型如下:
参数说明:
返回值说明:
accept返回的套接字是什么?
调用accept函数获取连接时,是从监听套接字中获取的。如果accept函数获取连接成功,此时会返回接收到的套接字对应的文件描述符。
监听套接字与accept函数返回的套接组的区别:
服务端获取连接
服务器获取连接时要注意:
void start()
{
while (1)
{
struct sockaddr_in peer;
memset(&peer, 0, sizeof(peer));
socklen_t len = sizeof(peer);
int sock = accept(_listen_sock, (struct sockaddr*)&peer, &len);
if (sock < 0)
{
std::cerr << "accept error, continue next" << std::endl;
continue;
}
std::string client_ip = inet_ntoa(peer.sin_addr);
int client_port = ntohs(peer.sin_port);
std::cout << "get a new link->" << sock << " [" << client_ip << "]:" <<client_port << std::endl;
}
}
服务端测试
现在我们做一下简单的测试,看看当前服务器是否能成功接收连接请求。在运行服务端时需要传入一个端口号作为服务端的端口号,然后我们用该端口号构造一个服务端对象,对服务端进行初始化之后启动服务器。
int main(int argc, char* argv[])
{
if (argc != 2)
{
std::cout << "Usage: " << argv[0] << " port" << std::endl;
exit(1);
}
int port = atoi(argv[1]);
TcpServer* svr = new TcpServer(port);
svr->InitServer();
svr->start();
return 0;
}
服务端运行之后,通过netstat命令可以查看到一个程序名为TcpServer的服务程序,它绑定的端口就是8081,而由于服务器绑定的是INADDR_ANY,因此该服务器的本地IP地址是0.0.0.0,这就意味着该TCP服务器可以读取本地任何一张网卡里面的数据。此外,最重要的是当前服务器所处的状态是LISTEN状态,表明当前服务器可以接收外部的请求连接。
虽然现在还没有编写客户端相关的代码,但是我们可以使用telnet命令远程登录到该服务器,因为telnet底层实际采用的就是TCP协议。
使用telnet命令连接当前TCP服务器后可以看到,此时服务器接收到了一个连接,为该连接提供服务的套接字对应的文件描述符就是4。因为0、1、2是默认打开的,而3号文件描述符在初始化服务器时分配给了监听套接字,因此当第一个客户端发起连接请求时,为该客户端提供服务的套接字对应的文件描述符就是4。
当然,也可以直接用浏览器来访问这个TCP服务器,因为浏览器常见的应用层协议是http或https,其底层对应的也是TCP协议,因此浏览器也可以向当前这个TCP服务器发起连接请求。
现在TCP服务器已经能够获取到连接请求了,下面就是要对获取到的连接进行处理。但此时为客户端提供服务的不是监听套接字,而是accept函数返回的套接字,下面就将其称为服务套接字。
为了让通信双方都能看到对应的消息,我们这里就实现一个简单的回声TCP服务器,服务端在为客户端提供服务时就简单地让客户端发来的数据进行输出,并且客户端发来的数据重新发回客户端即可。当客户端拿到服务端的相应数据后再将数据进行打印输出,此时就能确保服务端和客户端能够正常通信了。
read函数
TCP服务器读取数据的函数叫做read,该函数的函数原型如下:
参数说明:
返回值说明:
read返回0时表示对端连接关闭
这实际和本地进程间通信中的管道通信是类似的,当使用管道进行通信时,可能会出现如下情况:
这里的写端就对应客户端,如果客户端将连接关闭了,那么此时客户端将套接字当中的信息读完后就会读到0,因此如果服务端调用read函数后得到的返回值为0,此时服务端就不必再为该客户端提供服务了。
write函数
TCP服务器写入数据的函数叫做write,该函数的函数原型如下:
参数说明:
返回值说明:
当服务端调用read函数接受到客户端的数据后,就可以再调用write函数将该数据再响应给客户端。
服务端处理请求
需要注意的是,服务端读取数据是服务套接字中读取的,而写入数据的时候也是写入进服务套接字的。也就是说这里为客户端提供服务的套接字,即可以读取数据也可以写入数据,这就是TCP全双工的通信的体现。
在从服务套接字中读取客户端发来的数据时,如果调用read函数之后得到的返回值为0,或者读取出错了,此时就应该直接将服务套接字对应的文件描述符关闭。因为文件描述符的本质就是数组的下标,因此文件描述符的资源是有限的,如果我们一直占用,那么可用的文件描述符就后越来越少,因此服务完客户端后要及时关闭对应的文件描述符,否则会导致文件描述符泄漏。
void start()
{
while (1)
{
struct sockaddr_in peer;
memset(&peer, 0, sizeof(peer));
socklen_t len = sizeof(peer);
int sock = accept(_listen_sock, (struct sockaddr*)&peer, &len);
if (sock < 0)
{
std::cerr << "accept error, continue next" << std::endl;
continue;
}
std::string client_ip = inet_ntoa(peer.sin_addr);
int client_port = ntohs(peer.sin_port);
std::cout << "get a new link->" << sock << " [" << client_ip << "]:" <<client_port << std::endl;
// 处理请求
Service(sock, client_ip, client_port);
}
}
void Service(int sock, std::string client_ip, int client_port)
{
char buffer[1024];
while (1)
{
ssize_t size = read(sock, buffer, sizeof(buffer));
if (size > 0)
{
buffer[size] = 0;
std::cout << "[" << client_ip << ":" << client_port << "]# " << buffer << std::endl;
write(sock, buffer, size);
}
else if (size == 0)
{
std::cout << client_ip << ":" << client_port << " close!" << std::endl;
break;
}
else
{
std::cerr << sock << " read error!" << std::endl;
break;
}
}
close(sock); // 归还文件描述符
std::cout << client_ip << ":" << client_port << " service done!" << std::endl;
}
同样地,我们将客户端也封装成一个类,当我们定义出一个客户端对象后也需要对其进行初始化,而初始化客户端唯一要做的就是创建套接字。而客户端在创建socket套接字时,参数设置与服务端创建套接字是一样的。
客户端不需要进行绑定和监听:
此外,客户端必须要知道它要连接的服务端的IP地址和端口号,因此客户端除了要有自己的套接字之外,还需要知道服务端的IP地址和端口号,这样客户端才能够通过套接字向指定服务器进行通信。
class TcpClient
{
public:
TcpClient(std::string server_ip, int server_port)
: _sock(-1), _server_ip(server_ip), _server_port(server_port)
{}
void InitClient()
{
_sock = socket(AF_INET, SOCK_STREAM, 0);
if (_sock < 0)
{
std::cerr << "socket error" << std::endl;
exit(2);
}
}
~TcpClient()
{
if (_sock >= 0) close(_sock);
}
private:
int _sock; // 套接字
std::string _server_ip; // 服务端IP地址
int _server_port; // 服务端端口号
};
由于客户端不需要绑定,也不需要监听,因此客户端创建完套接字之后就可以向服务端发起请求。
connect函数
发起连接请求的函数叫做connect函数,该函数的原型如下:
参数说明:
返回值说明:
客户端连接服务器
需要注意的是,客户端不是不需要进行绑定,而是不需要程序员进行绑定,当客户端向服务端发起连接请求时,系统会给客户端随机指定一个端口号进行绑定。因为通信双方都必须要有IP地址和端口号,否则无法唯一标识通信双方。也就是说,如果connect函数调用成功了,客户端本地会随机给该客户端绑定一个端口号发送给对端服务器。
此外,调用connect函数向服务端发起连接请求时,需要传入服务端对应的网络信息,否则connect函数也不知道该客户端到底是要向哪一个服务端发起连接请求。
void start()
{
struct sockaddr_in peer;
memset(&peer, 0, sizeof(peer));
peer.sin_family = AF_INET;
peer.sin_addr.s_addr = inet_addr(_server_ip.c_str();
peer.sin_port = htons(_server_port);
int ret = connect(_sock, (struct sockaddr*)&peer, sizeof(peer));
if (ret == 0)
{
std::cout << "connect success..." << std::endl;
}
else
{
std::cerr << "connect fail..." << std::endl;
exit(3);
}
}
由于我们实现的是一个简单的回声服务器,因此当客户端连接到服务端之后,客户端就可以向服务端发送数据了,这里我们可以让客户端将用户输入的数据发送给服务器,发送时调用write函数向套接字当中写入数据即可。
当客户端将数据发送给服务端后,由于服务端读取到数据后还会进行回显,因此客户端在发送数据后还需要调用read函数读取服务端的相应数据,然后将该相应数据进行打印,以确定双方通信无误。
void Request()
{
std::string msg;
char buffer[1024];
while (1)
{
std::cout << "Please Enter# ";
getline(std::cin, msg);
write(_sock, msg.c_str(), msg.size());
ssize_t size = read(_sock, buffer, sizeof(buffer) - 1);
if (size > 0)
{
buffer[size] = 0;
std::cout << "server echo# " << buffer << std::endl;
}
else if (size == 0)
{
std::cout << "server close!" << std::endl;
break;
}
else
{
std::cerr << "read error!" << std::endl;
break;
}
}
}
在运行客户端程序时我们需要携带对应的IP地址和端口号,然后我们就可以通过服务端的IP地址和端口号构造出一个客户端对象,对客户端进行初始化后启动客户端即可。
int main(int argc, char* argv[])
{
if (argc != 3)
{
std::cout << "Usage: " << argv[0] << " server_ip server_port" << std::endl;
exit(1);
}
std::string server_ip = argv[1];
int server_port = atoi(argv[2]);
TcpClient* clt = new TcpClient(server_ip, server_port);
clt->InitClient();
clt->start();
return 0;
}
下面我们运行服务端和客户端,运行结果如下:
此时用netstat命令进行查看:
可以看到服务器是处于监听状态的。
当我们仅用一个客户端连接服务器时,这一个客户端能够正常享受到服务端的服务。但在这个客户端正在享受服务端的服务时,我们让另一个客户端也连接服务器,此时虽然该客户端可以连接成功,但是这个客户端发送给服务端的消息是不能在服务端进行打印的,也不能回显给该客户端。只有当第一个客户端退出之后,第二个客户端发送的数据才可以打印在服务端,也才可以回显给客户端。
即使这样,客户端为什么还会显示连接成功?
当服务端在给第一个客户端提供服务期间,第二个客户端向服务端发起的请求是成功的,只不过服务端没有调用accept函数将该连接获取上来罢了。
实际在底层会有一个连接队列,服务端有accept的新连接就会放在这个连接队列当中,而这个连接队列的最大长度就是通过listen函数的第二个参数决定的,服务端虽然没有获取第二个客户端发来的连接请求,但是在它看来,它是连接成功的。
如何解决上面的问题?
单执行流的服务器一次只能给一个客户端提供服务,此时服务器的资源并没有得到充分利用,因此服务器一般是不会写成单执行流的。要解决这个问题就需要将服务器改为多执行流,此时就要引入多进程或者多线程。
当服务端调用accept函数获取到新连接之后不是由当前执行流为该连接提供服务,而是当前执行流调用fork函数创建子进程,然后子进程为父进程获取到的连接提供服务。
由于父子进程是两个不同的执行流,当父进程调用fork创建出子进程之后,父进程就可以继续从监听套接字当中获取新连接,而不用关心获取上来的连接是否服务完毕。
子进程继承父进程的文件描述符表
需要注意的是,文件描述符表是隶属于一个进程的,子进程创建之后会继承父进程的文件描述符表。
但当父进程创建子进程之后,此时父进程文件描述符表的变化不会影响子进程。最典型的代表就是匿名管道,父子进程在使用匿名管道进行通信时,父进程先调用pipe函数得到两个文件描述符,一个是管道读端的文件描述符,一个是管道写端的文件描述符,此时父进程创建出来的子进程就会继承这两个文件描述符,之后父子进程一个关闭管道的读端,一个关闭管道的写端,这时父子进程创建文件描述符表的变化是不会相互影响的,此后父子进程就可以通过这个管道进行单向通信了。
对于套接字文件也是一样的,父进程创建的子进程也会继承父进程的套接字文件,此时子进程就能够对特定的套接字文件进行读写操作,进而完成对对应客户端的服务。
等待子进程问题
当父进程创建出子进程之后,父进程是需要等待子进程退出的,否则子进程会变成僵尸进程,进而造成内存泄漏。因此服务端创建子进程后需要调用wait或waitpid函数对子进程进行等待。
阻塞式等待与非阻塞式等待:
总之,服务端要等待子进程退出,无论是采用阻塞式等待还是非阻塞式等待,都不尽人意。此时我们可以考虑让服务端不等待子进程退出。
不等待子进程的方式
让父进程不等待子进程退出,有两种方法:
捕捉SIGCHLD信号
当子进程退出时会给父进程发送SIGCHLD信号,如果父进程将SIGCHLD信号进行捕捉,并且将信号的处理动作设置为忽略,此时父进程就只需专心处理自己的工作,不必关心子进程了。
这种方式实现起来非常简单,也是比较推荐的一种做法。
void start()
{
signal(SIGCHLD, SIG_IGN);
while (1)
{
struct sockaddr_in peer;
memset(&peer, 0, sizeof(peer));
socklen_t len = sizeof(peer);
int sock = accept(_listen_sock, (struct sockaddr*)&peer, &len);
if (sock < 0)
{
std::cerr << "accept error, continue next" << std::endl;
continue;
}
std::string client_ip = inet_ntoa(peer.sin_addr);
int client_port = ntohs(peer.sin_port);
std::cout << "get a new link->" << sock << " [" << client_ip << "]:" <<client_port << std::endl;
// 处理请求
pid_t id = fork();
if (id == 0)
{
Service(sock, client_ip, client_port);
exit(0);
}
}
}
让孙子进程提供服务
我们也可以让服务端创建出来的子进程进行fork创建出孙子进程,让孙子进程为客户端提供服务。
这里的命名说明:
我们让爸爸进程创建完孙子进程后立刻退出,此时爷爷进程调用wait或者waitpid函数等待爸爸进程就能立刻等待成功,然后爷爷进程就可以继续使用accept函数获取客户端的连接请求。
不需要等待孙子进程退出
由于爸爸进程创建完孙子进程之后就立刻退出了,因此此时为客户端提供服务的进程变为了孤儿进程,该进程会被系统领养,当孙子进程为客户端提供完服务退出后系统会回收孙子进程,所以爷爷进程是不需要等待孙子进程退出的。
关闭对应的文件描述符
爷爷进程调用accept函数获取到新连接之后,会让孙子进程为该连接提供服务,此时服务进程已经将文件描述符表继承给了爸爸进程,而爸爸进程又会调用fork函数创建出孙子进程然后再将文件描述符表继承给孙子进程。
而父子进程创建之后,它们各自的文件描述符表是独立的,不会相互影响。因此服务进程在调用fork函数之后,服务进程就不需要关心刚才从accept函数获取到的文件描述符了,此时服务进程就可以调用close函数将该文件描述符进行关闭。
同样的,对于爸爸进程和孙子进程来说,它们是不需要关心从爷爷进程继承下来的监听套接字的,因此爸爸进程可以将监听套接字关掉。
关闭文件描述符的必要性:
void start()
{
while (1)
{
struct sockaddr_in peer;
memset(&peer, 0, sizeof(peer));
socklen_t len = sizeof(peer);
int sock = accept(_listen_sock, (struct sockaddr*)&peer, &len);
if (sock < 0)
{
std::cerr << "accept error, continue next" << std::endl;
continue;
}
std::string client_ip = inet_ntoa(peer.sin_addr);
int client_port = ntohs(peer.sin_port);
std::cout << "get a new link->" << sock << " [" << client_ip << "]:" <<client_port << std::endl;
// 处理请求
// 1. 将SIGCHLD信号设置为忽略
// pid_t id = fork();
// if (id == 0)
// {
// Service(sock, client_ip, client_port);
// exit(0);
// }
// 2. 创建孙子进程
pid_t id = fork();
if (id == 0)
{
close(_listen_sock);
if (fork() > 0) exit(0);
Service(sock, client_ip, client_port);
exit(0);
}
}
}
创建进程的成本是很高的,创建进程时需要创建该进程对应的进程控制块(task_struct)、进程地址空间(mm_struct)、页表等数据结构。而创建线程的成本比创建进程的成本小很多,因为线程本质是在进程地址空间内运行,创建出来的线程会共享该进程的大部分数据,因此在实现多执行流的服务器时最好采用多线程进行实现。
当服务器进程调用accept函数获取到一个新连接之后,就可以直接创建一个线程,让该线程为对应客户端提供服务。
当然,主线程创建出新线程之后,也是需要等待新线程退出的,否则也会造成类似于僵尸进程这样的问题。但是对于线程来说,如果不想让主线程等待新线程退出,可以让创建出来的新线程调用pthread_detach函数进行线程分离,当这个线程退出系统时会自动回收该线程对应的资源。此时主线程就可以继续调用accept函数获取新连接,而让新线程去服务对于的客户端。
多个线程共享一张文件描述符表
文件描述符表维护的是进程与文件之间的对应关系,因此一个进程对应一张文件描述符表。而主线程创建出来的新线程依旧属于这个进程,因此创建线程时并不会为该线程创建独立的文件描述符表,所有的线程看到的同一张文件描述符表。
因此将主线程调用accept函数获取到一个文件描述符之后,其他新创建的线程是可以直接访问该文件描述符的。
需要注意的是,虽然新线程能够访问主线程accept上来的文件描述符,但此时新线程并不知道它所无误的客户端对应的是哪一个文件描述符,因此主线程创建新线程之后需要告诉新线程应该访问的文件描述符的值,也就是告诉每个新线程在服务客户端时,应该对哪一个套接字进行操作。
参数结构体
实际新线程在位客户端提供服务时就是调用Service函数,而调用Service函数是需要传入三个参数的,分别是客户端对应的套接字、IP地址和端口号。因此主线程创建新线程时需要给新线程传入三个参数,而实际在调用pthread_create函数创建新线程时,只能传入一个参数。
这是我们可以设计一个结构体Param,此时这三个参数就可以放到Param结构体当中,当主线程创建新线程时就可以定义一个Param对象,将客户端对应的套接字、IP地址和端口号设计进这个Param对象当中,然后将Param对象的地址作为新线程执行例程的参数进行传入。
此时新线程在执行例程当中再将这个void类型的参数强转为Param类型,然后就能够拿到客户端对应的套接字、IP地址和端口号了。
class Param
{
public:
Param(int sock, std::string ip, int port)
: _sock(sock), _ip(ip), _port(port)
{}
public:
int _sock;
std::string _ip;
int _port;
};
Service函数定义为静态成员函数
由于调用pthread_create函数创建线程时,新线程的执行例程的第一个参数为void*,如果我们要将这个执行例程定义到类内,就需要将其定义为静态成员函数,否则这个执行例程的第一个参数是隐藏的this指针。
在线程的执行例程中会调用Service函数,由于执行例程是静态成员函数,静态成员函数无法调用非静态成员函数,因此我们需要将Service函数定义为静态成员函数。恰好Service函数内部进行的操作都是与类无关的,因此我们直接在Service函数前面加上一个static即可。
整体代码如下:
void start()
{
signal(SIGCHLD, SIG_IGN); // 忽略SIGCHLD信号
while (1)
{
struct sockaddr_in peer;
memset(&peer, 0, sizeof(peer));
socklen_t len = sizeof(peer);
int sock = accept(_listen_sock, (struct sockaddr*)&peer, &len);
if (sock < 0)
{
std::cerr << "accept error, continue next" << std::endl;
continue;
}
std::string client_ip = inet_ntoa(peer.sin_addr);
int client_port = ntohs(peer.sin_port);
std::cout << "get a new link->" << sock << " [" << client_ip << "]:" <<client_port << std::endl;
// 处理请求
// 1. 将SIGCHLD信号设置为忽略
// pid_t id = fork();
// if (id == 0)
// {
// Service(sock, client_ip, client_port);
// exit(0);
// }
// 2. 创建孙子进程
// pid_t id = fork();
// if (id == 0)
// {
// close(_listen_sock);
// if (fork() > 0) exit(0);
// Service(sock, client_ip, client_port);
// exit(0);
// }
// 3. 多线程
Param* p = new Param(sock, client_ip, client_port);
pthread_t tid;
pthread_create(&tid, nullptr, HandleRequest, (void*)p);
}
}
static void* HandleRequest(void* arg)
{
pthread_detach(pthread_self());
Param* p = (Param*)arg;
Service(p->_sock, p->_ip, p->_port);
delete p;
return nullptr;
}
static void Service(int sock, std::string client_ip, int client_port)
{
char buffer[1024];
while (1)
{
ssize_t size = read(sock, buffer, sizeof(buffer));
if (size > 0)
{
buffer[size] = 0;
std::cout << "[" << client_ip << ":" << client_port << "]# " << buffer << std::endl;
write(sock, buffer, size);
}
else if (size == 0)
{
std::cout << client_ip << ":" << client_port << " close!" << std::endl;
break;
}
else
{
std::cerr << sock << " read error!" << std::endl;
break;
}
}
close(sock); // 归还文件描述符
std::cout << client_ip << ":" << client_port << " service done!" << std::endl;
}
当前多线程版服务器存在的问题:
解决思路
针对这两个问题,对应的解决思路如下:
引入线程池
解决这里的问题我们就需要在服务端内部引入线程池,因为线程池的存在就是为了避免处理短时间任务时创建与销毁线程的代价,此外,线程池还能够保证内核充分利用,放置过分调度。
其中在线程池里面有一个任务队列,当有新的任务到来的时候,就可以将任务Push到线程池当中,在线程池当中我们默认创建了5个线程,这些线程不断检测任务队列当中是否有任务,如果有任务就拿出任务,然后调用任务对应的Run函数对该任务进行处理,如果线程池当中没有任务那么当前线程就会进入休眠装填。
对于线程池的讲解和代码在这篇博客中。这里不再赘述。
#pragma once
#include
#include
#include
#include
#define NUM 5 // 定义总线程的数量
template <class T>
class ThreadPool
{
private:
bool IsEmpty() // 任务队列是否为空?
{
return _task_queue.size() == 0;
}
void LockQueue() // 争夺互斥锁
{
pthread_mutex_lock(&mutex);
}
void UnLockQueue() // 解锁
{
pthread_mutex_unlock(&mutex);
}
void wait() // 等待条件变量
{
pthread_cond_wait(&_cond, &_mutex);
}
void WakeUp() // 唤醒线程,让它执行任务
{
pthread_cond_signal(&_cond);
}
public:
// 初始化互斥锁和条件变量
ThreadPool(int num = NUM)
: _thread_num(num)
{
pthread_mutex_init(&_mutex, nullptr);
pthread_cond_init(&_cond, nullptr);
}
// 销毁互斥锁和条件变量
~ThreadPool()
{
pthread_mutex_destroy(&_mutex);
pthread_cond_destroy(&_cond);
}
// 创建多线程,传入this指针
void ThreadPoolInit()
{
pthread_t tid;
for (int i = 0; i < _thread_num; ++i)
{
// 参数传入this指针
ptnread_create(&tid, nullptr, Routine, this);
}
}
// 线程池中线程的执行例程
// 每次线程的执行例程都是死循环,以达到重复执行任务的效果
static void* Routine(void* arg)
{
pthread_detach(pthread_self());
ThreadPool* self = (ThreadPool*)arg;
while (true)
{
self->LockQueue(); // 每次执行任务前竞争互斥锁
while (self->IsEmpty()) // 如果任务队列为空,进行等待
{
self->wait();
}
T task; // 创建任务类
self->pop(task); // 将任务队列中的元素赋给任务类
self->UnLockQueue(); // 解锁
task.Run(); // 执行任务
}
}
void Push(const T& task)
{
LockQueue(); // 竞争锁
_task_queue.push(task); // 将任务类放入任务队列
WakeUp(); // 唤醒线程,让其去执行任务
UnLockQueue(); // 解锁
}
void Pop(T& task)
{
task = _task_queue.front(); // 拿到任务
_task_queue.pop(); // 删除任务类中的一个任务
}
private:
std::queue<T> _task_queue; // 任务队列
int _thread_num; // 线程数量
pthread_mutex_t _mutex; // 任务队列是临界资源,需要加锁
pthread_cond_t _cond; // 条件变量,任务队列为空时线程需要进行等待
}
服务类新增线程池成员
现在服务端引入了线程池,因此在服务类中需要新增一个指向线程池的指针成员:
现在当服务进程调用accept函数获取一个连接请求后,就会根据该客户端的套接字、IP地址以及端口号创建出一个任务,然后调用线程池提供的Push接口将该任务塞入任务队列。
这实际也是一个生产者消费者模型,其中服务进程就是任务的生产者,而线程池当中的若干线程就是消费者,它们交易的场所就是线程池当中的任务队列。
class TcpServer
{
public:
TcpServer(int port)
: _port(port)
{}
void InitServer()
{
_listen_sock = socket(AF_INET, SOCK_STREAM, 0);
if (_listen_sock < 0)
{
std::cerr << "socket error" << std::endl;
exit(2);
}
// 绑定
struct sockaddr_in local;
memset(&local, 0, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(_port);
local.sin_addr.s_addr = INADDR_ANY;
int ret = bind(_listen_sock, (struct sockaddr*)&local, sizeof(local));
if (ret < 0)
{
std::cerr << "bind error" << std::endl;
exit(3);
}
// 监听
ret = listen(_listen_sock, BACKLOG);
if (ret < 0)
{
std::cerr << "listen error" << std::endl;
exit(4);
}
_tp = new ThreadPool<Task>(); // 构造线程池对象
}
~TcpServer()
{
if (_listen_sock >= 0)
close(_listen_sock);
}
void start()
{
//signal(SIGCHLD, SIG_IGN); // 忽略SIGCHLD信号
_tp->ThreadPoolInit();
while (1)
{
struct sockaddr_in peer;
memset(&peer, 0, sizeof(peer));
socklen_t len = sizeof(peer);
int sock = accept(_listen_sock, (struct sockaddr*)&peer, &len);
if (sock < 0)
{
std::cerr << "accept error, continue next" << std::endl;
continue;
}
std::string client_ip = inet_ntoa(peer.sin_addr);
int client_port = ntohs(peer.sin_port);
std::cout << "get a new link->" << sock << " [" << client_ip << "]:" <<client_port << std::endl;
// 处理请求
// 1. 将SIGCHLD信号设置为忽略
// pid_t id = fork();
// if (id == 0)
// {
// Service(sock, client_ip, client_port);
// exit(0);
// }
// 2. 创建孙子进程
// pid_t id = fork();
// if (id == 0)
// {
// close(_listen_sock);
// if (fork() > 0) exit(0);
// Service(sock, client_ip, client_port);
// exit(0);
// }
// 3. 多线程
// Param* p = new Param(sock, client_ip, client_port);
// pthread_t tid;
// pthread_create(&tid, nullptr, HandleRequest, (void*)p);
Task task(sock, client_ip, client_port);
_tp->push(task);
}
}
static void* HandleRequest(void* arg)
{
pthread_detach(pthread_self());
Param* p = (Param*)arg;
Service(p->_sock, p->_ip, p->_port);
delete p;
return nullptr;
}
static void Service(int sock, std::string client_ip, int client_port)
{
char buffer[1024];
while (1)
{
ssize_t size = read(sock, buffer, sizeof(buffer));
if (size > 0)
{
buffer[size] = 0;
std::cout << "[" << client_ip << ":" << client_port << "]# " << buffer << std::endl;
write(sock, buffer, size);
}
else if (size == 0)
{
std::cout << client_ip << ":" << client_port << " close!" << std::endl;
break;
}
else
{
std::cerr << sock << " read error!" << std::endl;
break;
}
}
close(sock); // 归还文件描述符
std::cout << client_ip << ":" << client_port << " service done!" << std::endl;
}
private:
int _listen_sock; // 监听套接字
int _port; // 端口号
ThreadPool<Task>* _tp;
};
设计任务类
现在我们要做的就是设计一个任务类,该任务类当中需要包含客户端对应的套接字、IP地址、端口号,表示该任务是为哪一个客户端提供服务,对应操作的套接字是哪一个。
此外,任务类中需要包含一个Run方法,当线程池中的线程拿到任务后就会直接调用这个Run方法对该任务进行处理,而实际处理这个任务的方法就是服务类当中的Service函数,服务端就是通过调用Service函数为客户端提供服务的。
我们可以直接拿出服务类当中的Service函数,将其放到任务类当中作为任务类的Run方法,但这实际不利于软件分层。我们可以给任务类新增一个仿函数成员,当执行任务类当中的Run方法处理任务时就可以以回调的方式处理该任务。
class Task
{
public:
Task(){}
Task (int sock, std::string client_ip, int client_port)
: _sock(sock), _client_ip(client_ip), _client_port(client_port)
{}
void Run()
{
_handler(_sock, _client_ip, _client_port);
}
private:
int _sock;
std::string _client_ip;
int _client_port;
Handler _handler;
};
注意:当任务队列当中有任务时,线程池当中的线程先定义出一个Task对象,然后将这个Task对象作为输出型参数调用任务队列的Pop函数,从任务队列当中获取任务,因此Task类除了提供带参的构造函数以外,还需要提供一个无参的构造函数,方便我们定义无参对象。
设计Handler类
此时需要再设计一个Handler类,在Handler类当中对 () 操作符进行重载,将 == ()==操作符的执行动作重载为执行Service函数的代码。
class Handler
{
public:
Handler(){}
~Handler(){}
void operator()(int sock, std::string client_ip, int client_port)
{
char buffer[1024];
while (1)
{
ssize_t size = read(sock, buffer, sizeof(buffer)-1);
if (size > 0)
{
buffer[size] = 0;
std::cout << client_ip << ":" << client_port << "# " << buffer << std::endl;
write(sock, buffer, size);
}
else if (size == 0)
{
std::cout << client_ip << ":" << client_port << " close!" << std::endl;
break;
}
else
{
std::cerr << sock << " read error!" << std::endl;
break;
}
}
close(sock);
std::cout << client_ip << ":" << client_port << " service done!" << std::endl;
}
};
实际我们可以让服务器处理不同的任务,当前服务器只是在进行字符串的回显处理,而实际要这么处理这个任务完全是由任务类当中的handler成员来决定的。
如果想要让服务器处理其他任务,只需要修改Handler类当中对 () 的重载函数就行了,而服务器的初始化、启动服务器以及线程池的代码都是不需要更改的,这就叫做把通信功能和业务逻辑在软件上进行解耦。