目录
- 简单的TCP网络程序
- 服务端创建套接字
- 服务端绑定
- 服务端监听
- 服务端获取连接
- 服务端处理请求
- 单执行流服务器的弊端
- 多进程版TCP网络程序
- 捕捉SIGCHLD信号
- 让孙子进程提供服务
- 多线程版的TCP网络程序
- 客户端创建套接字
- 客户端链接服务器
- 客户端发起请求
- 线程池版的TCP网络程序
简单的TCP网络程序
我们将TCP服务器封装成一个类:
class TcpServer
{
public:
TcpServer(uint16_t port, std::string ip = "") : _port(port), _ip(ip), listensock(-1)
{
}
~TcpServer()
{
}
private:
uint16_t _port;
std::string _ip;
int listensock;
};
服务端创建套接字
首先我们要做的就是初始化服务器,而初始化服务器最先做的就是要创建套接字,TCP服务器在调用socket函数创建套接字时,参数设置如下:
- 协议家族选择
AF_INET
,因为我们要进行的是网络通信。 - 创建套接字时所需的服务类型应该是
SOCK_STREAM
,因为我们编写的是TCP服务器SOCK_STREAM
提供的就是一个有序的、可靠的、全双工的、基于连接的流式服务。 - 协议类型默认设置为0即可。
如果创建套接字后获得的文件描述符是小于0的,说明套接字创建失败,此时也就没必要进行后续操作了,直接终止程序即可。
void Serverinit()
{
// 1.创建socket
listensock = socket(AF_INET, SOCK_STREAM, 0);
if (listensock < 0)
{
logMessage(FATAL, "create socket error:%d:%s", errno, strerror(errno));
exit(1);
}
logMessage(NORMAL, "create socket sucess:%d", listensock);
}
服务端绑定
套接字创建完毕后我们实际只是在系统层面上打开了一个文件,该文件还没有与网络关联起来,因此创建完套接字后我们还需要调用bind函数进行绑定操作。
TCP服务端绑定与UDP基本一致:
void Serverinit()
{
// 1.创建socket
listensock = socket(AF_INET, SOCK_STREAM, 0);
if (listensock < 0)
{
logMessage(FATAL, "create socket error:%d:%s", errno, strerror(errno));
exit(1);
}
logMessage(NORMAL, "create socket sucess:%d", listensock);
// 2. bind
struct sockaddr_in local;
memset(&local, 0, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(_port);
local.sin_addr.s_addr = _ip.empty() ? INADDR_ANY : inet_addr(_ip.c_str());
if (bind(listensock, (struct sockaddr *)&local, sizeof(local)) < 0)
{
logMessage(FATAL, "bond error:%d:%s", errno, strerror(errno));
exit(2);
}
}
服务端监听
UDP服务器的初始化操作只有两步,第一步就是创建套接字,第二步就是绑定。而TCP服务器是面向连接的,客户端在正式向TCP服务器发送数据之前,需要先与TCP服务器建立连接,然后才能与服务器进行通信。
因此TCP服务器需要时刻注意是否有客户端发来连接请求,此时就需要将TCP服务器创建的套接字设置为监听状态。
listen函数
设置套接字为监听状态的函数叫做listen,该函数的函数原型如下:
int listen(int sockfd, int backlog);
参数说明:
sockfd
:需要设置为监听状态的套接字对应的文件描述符。backlog
:全连接队列的最大长度。如果有多个客户端同时发来连接请求,此时未被服务器处理的连接就会放入连接队列,该参数代表的就是这个全连接队列的最大长度,一般不要设置太大,设置为5或20即可。
返回值说明:
- 监听成功返回0,监听失败返回-1,同时错误码会被设置。
服务器监听
TCP服务器在创建完套接字和绑定后,需要再进一步将套接字设置为监听状态,监听是否有新的连接到来。如果监听失败也没必要进行后续操作了,因为监听失败也就意味着TCP服务器无法接收客户端发来的连接请求,因此监听失败我们直接终止程序即可。
void Serverinit()
{
// 1.创建socket
listensock = socket(AF_INET, SOCK_STREAM, 0);
if (listensock < 0)
{
logMessage(FATAL, "create socket error:%d:%s", errno, strerror(errno));
exit(1);
}
logMessage(NORMAL, "create socket sucess:%d", listensock);
// 2. bind
struct sockaddr_in local;
memset(&local, 0, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(_port);
local.sin_addr.s_addr = _ip.empty() ? INADDR_ANY : inet_addr(_ip.c_str());
if (bind(listensock, (struct sockaddr *)&local, sizeof(local)) < 0)
{
logMessage(FATAL, "bond error:%d:%s", errno, strerror(errno));
exit(2);
}
// 3. 建立连接
if (listen(listensock, gbacklog) < 0)
{
logMessage(FATAL, "listen error:%d:%s", errno, strerror(errno));
exit(3);
}
logMessage(NORMAL, "init server sucess");
}
服务端获取连接
TCP服务器初始化后就可以开始运行了,但TCP服务器在与客户端进行网络通信之前,服务器需要先获取到客户端的连接请求。
accept函数
获取连接的函数叫做accept,该函数的函数原型如下:
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
参数说明:
sockfd
:特定的监听套接字,表示从该监听套接字中获取连接。addr
:对端网络相关的属性信息,包括协议家族、IP地址、端口号等。addrlen
:调用时传入期望读取的addr结构体的长度,返回时代表实际读取到的addr结构体的长度,这是一个输入输出型参数。
返回值说明:
- 获取连接成功返回接收到的套接字的文件描述符,获取连接失败返回-1,同时错误码会被设置。
accept函数返回值
调用accept函数过程中,我们是从监听套接字listensock中获取连接的,此时就会返回接收到套接字对应的文件描述符。accept函数中的监听套接字是为了让我们不断来获取连接的,而它返回的套接字才是真正为我们为您提供服务的。
void start()
{
while (true)
{
// sleep(1);
// 4. 获取连接
struct sockaddr_in src;
socklen_t len = sizeof(src);
int serversock = accept(listensock, (struct sockaddr *)&src, &len);
if (serversock < 0)
{
logMessage(ERROR, "accept error:%d:%s", errno, strerror(errno));
continue;
}
}
}
服务端处理请求
此时服务端获取连接已经成功了,此时就需要对获取的连接进行处理,我们的客户端所接收的并不是监听套接字,而是accept函数返回的套接字,因为监听套接字一个连接完成就会继续连接下一个。
我们在这儿实现一个简单的回声服务器,服务端将客户端发出的数据进行打印,并将数据重新发回客户端,客户端拿到此数据以后在进行回显,这样就完成了服务端与客户端之间的通信。
read函数
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函数将该数据再响应给客户端。
服务端读取数据是从accept函数返回的套接字当中读取的,写入数据也是向accept函数返回的套接字当中写入的,所以为客户端提供的套接字是既可以读取数据,也可以写入数据的,这就是TCP全双工的体现。
在此我们还需注意的是从服务套接字中读取客户端发来的数据时,如果调用read函数后得到的返回值为0,或者读取出错了,此时就应该直接将服务套接字对应的文件描述符关闭。因为文件描述符本质就是数组的下标,因此文件描述符的资源是有限的,如果我们一直占用,那么可用的文件描述符就会越来越少,因此服务完客户端后要及时关闭对应的文件描述符,否则会导致文件描述符泄漏。
static void service(int sock, const std::string &clientip, const uint16_t &clientport)
{
char buffer[1024];
while (true)
{
ssize_t s = read(sock, buffer, sizeof(buffer) - 1);
if (s > 0) // 将发过来的数据当做字符串
{
buffer[s] = 0;
std::cout << clientip << ":" << clientport << "# " << buffer << std::endl;
}
else if (s == 0)
{
logMessage(NORMAL, "%s:%d shutdown, me too!!!", clientip.c_str(), clientport);
break;
}
else
{
logMessage(errno, "read socket error:%d %s", errno, strerror(errno));
break;
}
write(sock, buffer, strlen(buffer));
}
close(sock);
}
void start()
{
while (true)
{
// sleep(1);
// 4. 获取连接
struct sockaddr_in src;
socklen_t len = sizeof(src);
int serversock = accept(listensock, (struct sockaddr *)&src, &len);
if (serversock < 0)
{
logMessage(ERROR, "accept error:%d:%s", errno, strerror(errno));
continue;
}
// 获取连接成功
uint16_t client_port = htons(src.sin_port);
std::string client_ip = inet_ntoa(src.sin_addr);
logMessage(NORMAL, "link success, sereversock:%d | %s | %d", serversock, client_ip.c_str(), client_port);
// 开始通信
//(1) 单进程循环,一次只处理一个客户端,处理完成以后在处理下一个
service(serversock, client_ip, client_port);
}
}
此时尽管我们并没有编写客户端代码,但我们可以使用telnet
命令远程登录到该服务器,因为telnet
底层实际采用的就是TCP协议。
此时我们就可以看见客户端输入数据以后,服务端就将数据接收到并打印出来,而且服务端还将数据发送回客户端并进行打印。
我们还会发现,为该连接提供服务的套接字对应的文件描述符就是4。因为0、1、2是默认打开的,其分别对应标准输入流、标准输出流和标准错误流,而3号文件描述符在初始化服务器时分配给了监听套接字,因此当第一个客户端发起连接请求时,为该客户端提供服务的套接字对应的文件描述符就是4。
单执行流服务器的弊端
上面我们使用一个客户端链接服务器时,服务端与客户端之间可以正常通信,可是当我们在打开一个客户端以后,此时虽然在客户端显示连接是成功的,但这个客户端发送给服务端的消息既没有在服务端进行打印,服务端也没有将该数据回显给该客户端。
只有当第一个客户端退出后,服务端才会将第二个客户端发来是数据进行打印,并回显该第二个客户端。
通过上述实验现象就可以发现,对于单执行流服务器来说,我们必须是一个客户端退出后才会运行另一个客户端,而一个客户端正在运行而另一个客户端连接成功的原因就在于底层我们实际上会维护一个链接队列,服务端没有accept的新连接就会放到这个连接队列当中,当服务端在给第一个客户端提供服务期间,第二个客户端向服务端发起的连接请求时是成功的,只不过服务端没有调用accept函数将该连接获取上来罢了,而这个连接队列的最大长度就是通过listen函数的第二个参数来指定的,因此服务端虽然没有获取第二个客户端发来的连接请求,但是在第二个客户端那里显示是连接成功的。
由于单执行流的弊端,我们就需要听过多进程或者是多线程来解决。
多进程版TCP网络程序
我们可以将当前的单执行流服务器改为多进程版的服务器,当服务端调用accept函数获取到新连接后不是由当前执行流为该连接提供服务,而是当前执行流调用fork函数创建子进程,然后让子进程为父进程获取到的连接提供服务。
由于父子进程是两个不同的执行流,当父进程调用fork创建出子进程后,父进程就可以继续从监听套接字当中获取新连接,而不用关心获取上来的连接是否服务完毕。
我们知道,子进程创建成功以后,父子进程共享一个文件描述符表,但是父进程创建子进程后,由于进程间独立性,父子进程之间并不会相互影响,所以,父进程文件描述符的变化并不会影响子进程,就像匿名管道一样,父进程先调用pipe函数得到两个文件描述符,一个是管道读端的文件描述符,一个是管道写端的文件描述符,此时父进程创建出来的子进程就会继承这两个文件描述符,之后父子进程一个关闭管道的读端,另一个关闭管道的写端,这时父子进程文件描述符表的变化是不会相互影响的,此后父子进程就可以通过这个管道进行单向通信了。
对于套接字文件也是一样的,父进程创建的子进程也会继承父进程的套接字文件,此时子进程就能够对特定的套接字文件进行读写操作,进而完成对对应客户端的服务,此时父进程关闭监听套接字返回的文件描述符,并不会对子进程产生影响;
等待子进程问题
子进程创建成功以后,父进程需要等待子进程退出,要不就会造成子进程成为僵尸进程,而造成内存泄漏;
但是此时不论是阻塞等待还是非阻塞等待都会存在一定的问题:
- 如果服务端采用阻塞的方式等待子进程,那么服务端还是需要等待服务完当前客户端,才能继续获取下一个连接请求,此时服务端仍然是以一种串行的方式为客户端提供服务。
- 如果服务端采用非阻塞的方式等待子进程,虽然在子进程为客户端提供服务期间服务端可以继续获取新连接,但此时服务端就需要将所有子进程的PID保存下来,并且需要不断花费时间检测子进程是否退出。
不等待子进程退出方式
让父进程不等待子进程退出,常见的方式有两种:
- 捕捉SIGCHLD信号,将其处理动作设置为忽略。
- 让父进程创建子进程,子进程再创建孙子进程,最后让孙子进程为客户端提供服务。
捕捉SIGCHLD信号
当子进程退出时给父进程发送SIGCHLD信号,如果父进程将SIGCHLD信号进行捕捉,并将该信号的处理动作设置为忽略,此时父进程就只需专心处理自己的工作,不必关心子进程了。
void start()
{
signal(SIGCHLD, SIG_IGN); //主动忽略SIGCHLD信号,子进程退出,自动释放自己僵尸状态
while (true)
{
// sleep(1);
// 4. 获取连接
struct sockaddr_in src;
socklen_t len = sizeof(src);
int serversock = accept(listensock, (struct sockaddr *)&src, &len);
if (serversock < 0)
{
logMessage(ERROR, "accept error:%d:%s", errno, strerror(errno));
continue;
}
// 获取连接成功
uint16_t client_port = htons(src.sin_port);
std::string client_ip = inet_ntoa(src.sin_addr);
logMessage(NORMAL, "link success, sereversock:%d | %s | %d", serversock, client_ip.c_str(), client_port);
//(2) 多进程版本
pid_t id = fork();
assert(id != -1);
if(id == 0)
{
close(listensock);
service(serversock, client_ip, client_port);
exit(0);
}
close(serversock);
}
}
创建监控脚本,此时对代码进行测试,当服务端此时运行起来时,此时服务进程只有一个,该服务进程就是不断获取新连接的进程,而获取到新连接后也是由该进程创建子进程为对应客户端提供服务的。
此时创建客户端,我们会发现运行一个客户端,此时服务进程就会调用fork函数创建出一个子进程,由该子进程为这个客户端提供服务,再有一个客户端连接服务器,此时服务进程会再创建出一个子进程,让该子进程为这个客户端提供服务。
并且此时由于这两个客户端分别由两个不同的执行流提供服务,因此这两个客户端可以同时享受到服务,它们发送给服务端的数据都能够在服务端输出,并且服务端也会对它们的数据进行响应。
当客户端一个个退出后,在服务端对应为之提供服务的子进程也会相继退出,但无论如何服务端都至少会有一个服务进程,这个服务进程的任务就是不断获取新连接。
让孙子进程提供服务
命名说明:
- 爷爷进程:在服务端调用accept函数获取客户端连接请求的进程。
- 爸爸进程:由爷爷进程调用fork函数创建出来的进程。
- 孙子进程:由爸爸进程调用fork函数创建出来的进程,该进程调用Service函数为客户端提供服务。
我们让爸爸进程创建完孙子进程后就立即退出,此时爷爷进程调用wait/waitpid函数等待爸爸进程就会立即成功,就可以继续调用accept函数获取其他客户的连接请求;
由于爸爸进程创建完孙子进程后就立马退出,此时孙子进程就变成了孤儿进程,就会被1号进程领养,当孙子进程完成客户端的需求以后,就会被系统所回收,并不需要爷爷进行的等待;
关闭对应的文件描述符
因为创建完子进程以后父子进程独立,所以关闭父进程文件描述符并不会影响子进程,同样,对于子进程来说,他也并不关心监听套接字,因此也可以将监听套接字关闭掉;
对于服务进程来说,当它调用fork函数后就必须将从accept函数获取的文件描述符关掉。因为服务进程会不断调用accept函数获取新的文件描述符(服务套接字),如果服务进程不及时关掉不用的文件描述符,最终服务进程中可用的文件描述符就会越来越少。
而对于爸爸进程和孙子进程来说,还是建议关闭从服务进程继承下来的监听套接字。实际就算它们不关闭监听套接字,最终也只会导致这一个文件描述符泄漏,但一般还是建议关上。因为孙子进程在提供服务时可能会对监听套接字进行某种误操作,此时就会对监听套接字当中的数据造成影响。
void start()
{
// signal(SIGCHLD, SIG_IGN); //主动忽略SIGCHLD信号,子进程退出,自动释放自己僵尸状态
while (true)
{
// 4. 获取连接
struct sockaddr_in src;
socklen_t len = sizeof(src);
int serversock = accept(listensock, (struct sockaddr *)&src, &len);
if (serversock < 0)
{
logMessage(ERROR, "accept error:%d:%s", errno, strerror(errno));
continue;
}
// 获取连接成功
uint16_t client_port = htons(src.sin_port);
std::string client_ip = inet_ntoa(src.sin_addr);
logMessage(NORMAL, "link success, sereversock:%d | %s | %d", serversock, client_ip.c_str(), client_port);
// 开始通信
//(2.1) 孙子进程提供服务
pid_t id = fork();
assert(id != -1);
if(id == 0)
{
close(listensock);
if(fork() > 0)
exit(0);
service(serversock, client_ip, client_port);
exit(1);
}
waitpid(id, nullptr, 0);
close(serversock);
}
}
创建监控脚本,此时创建客户端,我们会发现运行一个客户端,此时爸爸进程就会调用fork函数创建出一个孙子进程,由该孙子进程为这个客户端提供服务,再有一个客户端连接服务器,此时爸爸进程会再创建出一个孙子进程,让该孙子进程为这个客户端提供服务,孙子进程的PID为1,表示他是孤儿进程,被1号进程领养。
并且此时由于这两个客户端分别由两个不同的孤儿进程提供服务,因此这两个客户端可以同时享受到服务,它们发送给服务端的数据都能够在服务端输出,并且服务端也会对它们的数据进行响应。
当客户端一个个退出后,在服务端对应为之提供服务的孙子进程也会相继退出,但无论如何服务端都至少会有一个服务进程,这个服务进程的任务就是不断获取新连接。
多线程版的TCP网络程序
创建进程的成本是很高的,创建进程时需要创建该进程对应的进程控制块(task_struct)、进程地址空间(mm_struct)、页表等数据结构。而创建线程的成本比创建进程的成本会小得多,因为线程本质是在进程地址空间内运行,创建出来的线程会共享该进程的大部分资源,因此在实现多执行流的服务器时最好采用多线程进行实现。
在调用accept函数以后,主线程就会创建一个新线程,此时新线程就会为服务端提供服务;但是主线程此时依然会等待新线程退出,如果主线程退出了,也会造成僵尸问题。此时我们就可以调pthreda_deach
函数来进行分离线程,当线程分离以后,主线程退出就不会影响新线程了。
文件描述符是否可以关闭
对于主线程和新线程来说,他们是共享一张文件描述符表的,对于新线程来说,主线程如果关闭其文件描述符,新线程也会随之关闭,所以文件描述符并不可以被主线程关闭,只有当新线程为客户端提供服务完成以后,才可以关闭文件描述符。
同样,监听套接字也不能新线程关闭,因为主线程需要通过监听套接字来获取连接,此时如果关闭了,就会造成主线程无法获取连接了。
实际新线程在为客户端提供服务时就是调用service函数,而调用service函数时是需要传入三个参数的,分别是客户端对应的套接字、IP地址和端口号。因此主线程创建新线程时需要给新线程传入三个参数,而实际在调用pthread_create函数创建新线程时,只能传入一个类型为void*的参数。
这时我们可以设计一个参数结构体ThreadData,此时这三个参数就可以放到ThreadData结构体当中,当主线程创建新线程时就可以定义一个ThreadData对象,将客户端对应的套接字、IP地址和端口号设计进这个ThreadData对象当中,然后将ThreadData对象的地址作为新线程执行例程的参数进行传入。
此时新线程在执行例程当中再将这个void类型的参数强转为ThreadData类型,然后就能够拿到客户端对应的套接字,IP地址和端口号,进而调用service函数为对应客户端提供服务。
static void service(int sock, const std::string &clientip, const uint16_t &clientport)
{
char buffer[1024];
while (true)
{
ssize_t s = read(sock, buffer, sizeof(buffer) - 1);
if (s > 0) // 将发过来的数据当做字符串
{
buffer[s] = 0;
std::cout << clientip << ":" << clientport << "# " << buffer << std::endl;
}
else if (s == 0)
{
logMessage(NORMAL, "%s:%d shutdown, me too!!!", clientip.c_str(), clientport);
break;
}
else
{
logMessage(errno, "read socket error:%d %s", errno, strerror(errno));
break;
}
write(sock, buffer, strlen(buffer));
}
close(sock);
}
class ThreadData
{
public:
ThreadData(int sock, std::string ip, uint16_t port) : _sock(sock), _ip(ip), _port(port)
{
}
~ThreadData()
{
}
public:
int _sock;
std::string _ip;
uint16_t _port;
};
class TcpServer
{
private:
const static int gbacklog = 20;
static void *threadRountine(void *args)
{
pthread_detach(pthread_self());
ThreadData *td = static_cast<ThreadData *>(args);
service(td->_sock, td->_ip, td->_port);
delete td;
return nullptr;
}
public:
TcpServer(uint16_t port, std::string ip = "") : _port(port), _ip(ip), listensock(-1)
{
}
void Serverinit()
{
// 1.创建socket
listensock = socket(AF_INET, SOCK_STREAM, 0);
if (listensock < 0)
{
logMessage(FATAL, "create socket error:%d:%s", errno, strerror(errno));
exit(1);
}
logMessage(NORMAL, "create socket sucess:%d", listensock);
// 2. bind
struct sockaddr_in local;
memset(&local, 0, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(_port);
local.sin_addr.s_addr = _ip.empty() ? INADDR_ANY : inet_addr(_ip.c_str());
if (bind(listensock, (struct sockaddr *)&local, sizeof(local)) < 0)
{
logMessage(FATAL, "bind error:%d:%s", errno, strerror(errno));
exit(2);
}
// 3. 建立连接
if (listen(listensock, gbacklog) < 0)
{
logMessage(FATAL, "listen error:%d:%s", errno, strerror(errno));
exit(3);
}
logMessage(NORMAL, "init server sucess");
}
void start()
{
while (true)
{
// sleep(1);
// 4. 获取连接
struct sockaddr_in src;
socklen_t len = sizeof(src);
int serversock = accept(listensock, (struct sockaddr *)&src, &len);
if (serversock < 0)
{
logMessage(ERROR, "accept error:%d:%s", errno, strerror(errno));
continue;
}
// 获取连接成功
uint16_t client_port = ntohs(src.sin_port);
std::string client_ip = inet_ntoa(src.sin_addr);
logMessage(NORMAL, "link success, sereversock:%d | %s | %d", serversock, client_ip.c_str(), client_port);
// 开始通信
// (3)多线程版本
ThreadData *td = new ThreadData(serversock, client_ip, client_port);
pthread_t tid;
pthread_create(&tid, nullptr, threadRountine, td);
//close(serversock);
}
~TcpServer()
{
}
private:
uint16_t _port;
std::string _ip;
int listensock;
};
注意:
这儿的threadRountine与service需要设置为静态函数,因为类内成员函数都会传递一个this指针,此时操作系统就会识别不了。
客户端创建套接字
我们在调用socket函数创建套接字过程与服务端是一样的,但是服务端是不需要进行绑定和监听的,服务端并不会去获取连接,所以也就不需要进行监听。
#include <iostream>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <cstring>
#include <cstdio>
void usage(std::string proc)
{
std::cout << "\nUsage: " << proc << " serverIp serverPort\n"
<< std::endl;
}
int main(int argc, char *argv[])
{
if (argc != 3)
{
usage(argv[1]);
exit(1);
}
uint16_t server_port = atoi(argv[2]);
std::string server_ip = argv[1];
//1.创建套接字
int sock = socket(AF_INET, SOCK_STREAM, 0);
if(sock < 0)
{
std::cerr << "socket error" << std::endl;
exit(2);
}
return 0;
}
客户端链接服务器
connet
发起连接请求的函数叫做connect,该函数的函数原型如下:
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
参数说明:
sockfd
:特定的套接字,表示通过该套接字发起连接请求。addr
:对端网络相关的属性信息,包括协议家族、IP地址、端口号等。addrlen
:传入的addr结构体的长度。
返回值说明:
- 连接或绑定成功返回0,连接失败返回-1,同时错误码会被设置。
//2.客户端连接服务器
struct sockaddr_in server;
memset(&server, 0, sizeof(server));
server.sin_family = AF_INET;
server.sin_port = htons(server_port);
server.sin_addr.s_addr = inet_addr(server_ip.c_str());
if(connect(sock, (struct sockaddr*)&server, sizeof(server)) < 0)
{
std::cerr << "connect error" << std::endl;
exit(3);
}
std::cout << "connect success" << std::endl;
客户端发起请求
我们实现一个简单的回声服务器,因此当客户端连接到服务端后,客户端就可以向服务端发送数据了,这里我们可以让客户端将用户输入的数据发送给服务端,发送时调用send函数向套接字当中写入数据即可。
当客户端将数据发送给服务端后,由于服务端读取到数据后还会进行回显,因此客户端在发送数据后还需要调用recv函数读取服务端的响应数据,然后将该响应数据进行打印,以确定双方通信无误。
//客户端发起请求
while(true)
{
std::string message;
std::cout << "请输入# ";
std::getline(std::cin, message);
if(message == "quit") break;
send(sock, message.c_str(), message.size(), 0);
char buffer[1024];
ssize_t s = recv(sock, buffer, sizeof(buffer) - 1, 0);
if(s > 0)
{
buffer[s] = 0;
std::cout << "server 回显: " << buffer << std::endl;
}
}
此时我们创建监控脚本进行测试,运行服务端,通过监控可以看到,此时只有一个服务线程,该服务线程就是主线程,它现在在等待客户端的连接到来。
当一个客户端连接到服务端后,此时主线程就会为该客户端构建一个参数结构体,然后创建一个新线程,将该参数结构体的地址作为参数传递给这个新线程,此时该新线程就能够从这个参数结构体当中提取出对应的参数,然后调用service函数为该客户端提供服务,因此在监控当中显示了两个线程,当第二个客户端发来连接请求时,主线程会进行相同的操作,最终再创建出一个新线程为该客户端提供服务,此时服务端当中就有了三个线程。
这两个客户端提供服务的也是两个不同的执行流,因此这两个客户端可以同时享受服务端提供的服务,它们发送给服务端的消息也都能够在服务端进行打印,并且这两个客户端也都能够收到服务端的回显数据。
此时无论有多少个客户端发来连接请求,在服务端都会创建出相应数量的新线程为对应客户端提供服务,而当客户端一个个退出后,为其提供服务的新线程也就会相继退出,最终就只剩下最初的主线程仍在等待新连接的到来。
线程池版的TCP网络程序
当前多线程版的服务器存在的问题:
每次主线程获取到一个连接时,就会随之创建一个新线程来给服务端提供服务,服务结束时新线程也就随之销毁,此时过程比较繁琐并且效率低下;而且如果存在需要大量线程提供服务时,随着新线程创建的越来越多,CPU的压力就越大,因为CPU要不断在这些线程之间来回切换,此时CPU在调度线程的时候,线程和线程之间切换的成本就会变得很高。此外,一旦线程太多,每一个线程再次被调度的周期就变长了,而线程是为客户端提供服务的,线程被调度的周期变长,客户端也迟迟得不到应答。
解决方法
此时就可以引入我们的线程池了:
- 可以在服务端预先创建一批线程,当有客户端请求连接时就让这些线程为客户端提供服务,此时客户端一来就有线程为其提供服务,而不是当客户端来了才创建对应的服务线程。
- 当某个线程为客户端提供完服务后,不要让该线程退出,而是让该线程继续为下一个客户端提供服务,如果当前没有客户端连接请求,则可以让该线程先进入休眠状态,当有客户端连接到来时再将该线程唤醒。
- 服务端创建的这一批线程的数量不能太多,此时CPU的压力也就不会太大。此外,如果有客户端连接到来,但此时这一批线程都在给其他客户端提供服务,这时服务端不应该再创建线程,而应该让这个新来的连接请求在全连接队列进行排队,等服务端这一批线程中有空闲线程后,再将该连接请求获取上来并为其提供服务。
threadPool
#pragma once
#include <iostream>
#include <vector>
#include <queue>
#include <unistd.h>
#include "thread.hpp"
#include "lockGuard.hpp"
#include "log.hpp"
#define NUM 3
template <class T>
class ThreadPool
{
public:
pthread_mutex_t *getMutex()
{
return &lock;
}
bool isEmpty()
{
return task_queue_.empty();
}
void waitCond()
{
pthread_cond_wait(&cond, &lock);
}
T getTask()
{
T t = task_queue_.front();
task_queue_.pop();
return t;
}
private:
ThreadPool(int thread_num = NUM) : num_(thread_num)
{
pthread_mutex_init(&lock, nullptr);
pthread_cond_init(&cond, nullptr);
for (int i = 1; i <= num_; i++)
{
threads_.push_back(new Thread(i, routine, this));
}
}
ThreadPool(const ThreadPool<T> &other) = delete;
const ThreadPool<T> &operator=(const ThreadPool<T> &other) = delete;
public:
static ThreadPool<T> *getThreadPool(int num = NUM)
{
if (thread_ptr == nullptr)
{
LockGuard lockguard(&mutex);
if (thread_ptr == nullptr)
{
thread_ptr = new ThreadPool<T>(num);
}
}
return thread_ptr;
}
// 生产
void run()
{
for (auto &iter : threads_)
{
iter->start();
// std::cout << iter->name() << "启动成功" << std::endl;
logMessage(NORMAL, "%s %s", iter->name().c_str(), "启动成功");
}
}
static void *routine(void *args)
{
ThreadData *td = (ThreadData *)args;
ThreadPool<T> *tp = (ThreadPool<T> *)td->args_;
while (true)
{
T task;
{
LockGuard lockguard(tp->getMutex());
while (tp->isEmpty())
tp->waitCond();
task = tp->getTask();
}
// 处理任务
task(td->name_);
}
}
void pushTask(const T &task)
{
LockGuard lockguard(&lock);
task_queue_.push(task);
pthread_cond_signal(&cond);
}
~ThreadPool()
{
for (auto &iter : threads_)
{
iter->join();
delete iter;
}
pthread_mutex_destroy(&lock);
pthread_cond_destroy(&cond);
}
private:
std::vector<Thread *> threads_; // 线程组
int num_;
std::queue<T> task_queue_; // 任务队列
pthread_mutex_t lock; // 互斥锁
pthread_cond_t cond; // 条件变量
static ThreadPool<T> *thread_ptr;
static pthread_mutex_t mutex;
};
template <typename T>
ThreadPool<T> *ThreadPool<T>::thread_ptr = nullptr;
template <typename T>
pthread_mutex_t ThreadPool<T>::mutex = PTHREAD_MUTEX_INITIALIZER;
thread.hpp
#pragma once
#include <iostream>
#include <string>
#include <functional>
#include <cstdio>
typedef void *(*func_t)(void *);
class ThreadData
{
public:
std::string name_;
void *args_;
};
class Thread
{
public:
Thread(int num, func_t callback, void *args) : func_(callback)
{
char nameBuffer[64];
snprintf(nameBuffer, sizeof nameBuffer, "Thread-%d", num);
name_ = nameBuffer;
tdata_.args_ = args;
tdata_.name_ = name_;
}
void start()
{
pthread_create(&tid_, nullptr, func_, (void *)&tdata_);
}
void join()
{
pthread_join(tid_, nullptr);
}
std::string name()
{
return name_;
}
~Thread()
{
}
private:
std::string name_; // 线程名
int num_; // 线程个数
func_t func_; // 回调函数
pthread_t tid_; // 线程ID
ThreadData tdata_;
};
服务类新增线程池成员
此时我们需要再线程池中新增一个指向线程池的指针成员,考虑到线程安全的问题,我们可以使用unique_ptr智能指针:
#pragma once
#include <iostream>
#include <cstdio>
#include <string>
#include <sys/types.h>
#include <sys/socket.h>
#include <cerrno>
#include <cstring>
#include <cassert>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>
#include <pthread.h>
#include <memory>
#include "ThreadPool/log.hpp"
#include "ThreadPool/threadPool.hpp"
#include "ThreadPool/Task.hpp"
class TcpServer
{
private:
const static int gbacklog = 20;
public:
TcpServer(uint16_t port, std::string ip = "")
: _port(port), _ip(ip), listensock(-1), _threadpool_ptr(ThreadPool<Task>::getThreadPool())
{
}
void Serverinit()
{
// 1.创建socket
listensock = socket(AF_INET, SOCK_STREAM, 0);
if (listensock < 0)
{
logMessage(FATAL, "create socket error:%d:%s", errno, strerror(errno));
exit(1);
}
logMessage(NORMAL, "create socket sucess:%d", listensock);
// 2. bind
struct sockaddr_in local;
memset(&local, 0, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(_port);
local.sin_addr.s_addr = _ip.empty() ? INADDR_ANY : inet_addr(_ip.c_str());
if (bind(listensock, (struct sockaddr *)&local, sizeof(local)) < 0)
{
logMessage(FATAL, "bind error:%d:%s", errno, strerror(errno));
exit(2);
}
// 3. 建立连接
if (listen(listensock, gbacklog) < 0)
{
logMessage(FATAL, "listen error:%d:%s", errno, strerror(errno));
exit(3);
}
logMessage(NORMAL, "init server sucess");
}
void start()
{
_threadpool_ptr->run();
// signal(SIGCHLD, SIG_IGN); //主动忽略SIGCHLD信号,子进程退出,自动释放自己僵尸状态
while (true)
{
// sleep(1);
// 4. 获取连接
struct sockaddr_in src;
socklen_t len = sizeof(src);
int serversock = accept(listensock, (struct sockaddr *)&src, &len);
if (serversock < 0)
{
logMessage(ERROR, "accept error:%d:%s", errno, strerror(errno));
continue;
}
// 获取连接成功
uint16_t client_port = ntohs(src.sin_port);
std::string client_ip = inet_ntoa(src.sin_addr);
logMessage(NORMAL, "link success, sereversock:%d | %s | %d", serversock, client_ip.c_str(), client_port);
// 开始通信
// (4)线程池版本
Task t(serversock, client_port, client_ip, service);
_threadpool_ptr->pushTask(t);
}
}
~TcpServer()
{
}
private:
uint16_t _port;
std::string _ip;
int listensock;
std::unique_ptr<ThreadPool<Task>> _threadpool_ptr;
};
Task任务类设计
我们在此是设计一个任务类,该任务类当中需要包含客户端对应的套接字、IP地址、端口号,表示该任务是为哪一个客户端提供服务,对应操作的套接字是哪一个。
此外,任务类当中需要包含一个函数方法,当线程池中的线程拿到任务后就会直接调用这个函数方法对该任务进行处理,而实际处理这个任务的方法就是服务类当中的service函数,服务端就是通过调用service函数为客户端提供服务的。
#pragma once
#include <iostream>
#include <string>
#include <functional>
using tfunc_t = std::function<void(int, const std::string &, uint16_t &, const std::string &)>;
class Task
{
public:
Task()
{
}
Task(int sock, uint16_t port, std::string ip, tfunc_t func)
: _sock(sock), _port(port), _ip(ip), _func(func)
{
}
void operator()(const std::string &name)
{
_func(_sock, _ip, _port, name);
}
private:
int _sock;
uint16_t _port;
std::string _ip;
tfunc_t _func;
};
假设我们此时需要做的任务是大小写切换,我们只需要更改service函数就可以,因为我们只需要将service函数当做传入我们构造的Task任务对象中,此时就会去回调我们service函数的方法:
static void change(int sock, const std::string &clientip,
const uint16_t &clientport, const std::string &thread_name)
{
char buffer[1024];
// read && write 可以直接被使用!
ssize_t s = read(sock, buffer, sizeof(buffer) - 1);
if (s > 0)
{
buffer[s] = 0; // 将发过来的数据当做字符串
std::cout << thread_name << "|" << clientip << ":" << clientport << "# " << buffer << std::endl;
std::string message;
char *start = buffer;
while(*start)
{
char c;
if(islower(*start)) c = toupper(*start);
else c = *start;
message.push_back(c);
start++;
}
write(sock, message.c_str(), message.size());
}
else if (s == 0) // 对端关闭连接
{
logMessage(NORMAL, "%s:%d shutdown, me too!", clientip.c_str(), clientport);
}
else
{
logMessage(ERROR, "read socket error, %d:%s", errno, strerror(errno));
}
close(sock);
}
客户端代码改写
因为我们此时使用的是线程池,线程池中的线程完成客户端传送过来的任务以后并不会退出,而是进入休眠状态,等待主线程连接以后下一个任务的到来,此时我们定义一个alive变量来标记我们新线程的状态,连接完成以后改变alive状态即可,此时就会进行下一个线程连接:
#include <iostream>
#include <string>
#include <cstdio>
#include <cstring>
#include <unistd.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <arpa/inet.h>
#include <netinet/in.h>
void usage(std::string proc)
{
std::cout << "\nUsage: " << proc << " serverIp serverPort\n"
<< std::endl;
}
// ./tcp_client targetIp targetPort
int main(int argc, char *argv[])
{
if (argc != 3)
{
usage(argv[0]);
exit(1);
}
std::string serverip = argv[1];
uint16_t serverport = atoi(argv[2]);
bool alive = false;
int sock = 0;
std::string line;
while (true) // TODO
{
if (!alive)
{
sock = socket(AF_INET, SOCK_STREAM, 0);
if (sock < 0)
{
std::cerr << "socket error" << std::endl;
exit(2);
}
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());
if (connect(sock, (struct sockaddr *)&server, sizeof(server)) < 0)
{
std::cerr << "connect error" << std::endl;
exit(3); // TODO
}
std::cout << "connect success" << std::endl;
alive = false;
}
std::cout << "请输入# ";
std::getline(std::cin, line);
if (line == "quit")
break;
ssize_t s = send(sock, line.c_str(), line.size(), 0);
if (s > 0)
{
char buffer[1024];
ssize_t s = recv(sock, buffer, sizeof(buffer) - 1, 0);
if (s > 0)
{
buffer[s] = 0;
std::cout << "server 回显# " << buffer << std::endl;
}
else if (s == 0)
{
alive = false;
close(sock);
}
}
else
{
alive = false;
close(sock);
}
}
return 0;
}
创建监控脚本,此时运行我们服务端,会发现此时在服务端就已经有了4个线程,其中有一个是接收新连接的服务线程,而其余的3个是线程池当中为客户端提供服务的线程,我们输入一段小写字母,此时服务端接收到请求以后立马做出处理并打印出数据,此时我们客户端回显出处理结果,并进行新的连接。
当第二个客户端发起连接请求时,服务端也会将其封装为一个任务类塞到任务队列,然后线程池当中的线程再从任务队列当中获取到该任务进行处理,此时也是不同的执行流为这两个客户端提供的服务,因此这两个客户端也是能够同时享受服务的,当客户端退出以后,服务端也随之退出
与之前不同的是,无论现在有多少客户端发来请求,在服务端都只会有线程池当中的3个线程为之提供服务,线程池当中的线程个数不会随着客户端连接的增多而增多,这些线程也不会因为客户端的退出而退出。