目录
一、TCP常用网络接口
在tcp协议中,也是需要使用socket创建套接字、bind绑定套接字的,这两个函数的使用在上一篇文章“UDP网络套接字”中已经详细介绍过,这里就不再一一赘述。
在这里,主要介绍tcp中所需要的不同于udp网络编程的接口。
1. 监听服务器
在理解监听前,先来看这么一个例子。在现实中我们买东西时遇到产品有问题,此时我们就会打电话给对应厂家,与厂家沟通。但是,我们是无法直接与厂家沟通的,而是要先打电话给客服,通过客服与厂家沟通。但是客服并不知道用户什么时候会打电话过来,所以客服就每天坐在电话前,当有用户打电话过来时,就立即接起电话与客户沟通。此时客服所处的状态就是“listen”状态。
在tcp协议中也是如此。大家知道,tcp协议的一个特性就是“有连接”。有连接就意味着客户在与服务端进行沟通之前,是需要先建立链接的。但是服务端并不知道客户端什么时候会建立连接,所以服务端便有了监听这一概念。通过设置sockfd为监听状态,来得知当前是否有客户端要进行连接。
该函数的名字是listen:
第一个参数sockfd就是socket接口的返回值。
第二个参数backlog的值代表的是监听队列的最大长度。监听队列此时大家还无法理解,就不过多讲,只需要知道这个值一般被设置为5、10等数字。该数字不能设置的太大。
2. 接收链接
tcp不同于udp直接接收用户传来的信息,在tcp中,在接收用户数据之前,还需要与用户建立连接。而建立连接的接口就是accept:
第一个参数sockfd,就是用socket接口返回的文件描述符。
addr是用于接收客户端的ip和端口等属性的,而addrlen则是addr这个结构体的大小。这两个都是输出型参数。
再来看accept的返回值:
当accept成功与客户端建立链接时,它会返回一个文件描述符。这里就很奇怪了,明明外面已经用socket接口创建套接字时已经返回了一个文件描述符了,为什么这个accept接口也会返回一个文件描述符呢?如果accept在建立连接成功时会返回一个文件描述符,这是不是就意味着如果存在多个客户端同时与服务端建立连接,那么服务端就会创建大量的文件描述符呢?而这些文件描述符又和socket接口返回的文件描述符之间有什么关系呢?在这里举一个例子帮助大家理解。
假设有一天你和你的朋友在外面玩时,到了一个美食街,这条街上面有许多的商家,每个商家外面都有人在吆喝拉客。当你们在这条街上走着的时候,出来一个人,假设他叫张三,张三跟你们说,“你们现在饿不饿呀,我们家的饭店物美价廉,量大管饱,你们要不要来尝尝呀”。你们听了,是觉得有点饿,于是就跟着张三去到了一家饭店。
当到了这家饭店后,张三就走到后厨大声喊道“有客人来了,来个服务员招呼一下”。此时就从后厨里面走出来了个服务员,叫李四。李四就将你们带到了一张桌子上等着你们点菜。
但是当你们进入饭店坐下后,张三并没有跟着一起过来,而是转身往外走,去向其他路过的人介绍自家的饭店,拉客去了。于是在你们吃饭的过程中,你们就看到周围的人越来越多,但无论进来多少人,张三总是在带着客人进入饭店后就离开,去拉新的客人。同时每个桌子旁都有一个服务员站在旁边,为客人提供服务。
在这个例子中,张三为什么不进入饭店和客人一起吃饭呢?因为张三是这个饭店的工作人员,它的职责是拉客,而不是和客人一起吃饭;同理,每个桌子旁为什么要有一个服务员呢?就是因为这个服务员的职责就是为这桌客人提供服务,当客人想要加菜、加位置或者有什么需求时,都可以通过这个服务员来告诉饭店,饭店也通过这个服务员将客人需要的东西交给他们。
在上面的这个例子中,饭店就是服务端,而张三就是用socket接口申请的文件描述符。它的作用就是对外获取连接,负责将客户端的连接请求交给服务端。
你和你的朋友以及其他客人,就是向服务端发起连接请求的客户端。而每个桌配备的服务员就是用accept接口申请的文件描述符。这些文件描述符的作用就是充当客户端与服务端的沟通渠道,服务端可以通过读写这些socket来与连接的客户端进行通信。
简单来讲,与udp协议直接用socket接口返回的文件描述符来与客户端通信不同,tcp协议中socket接口返回的文件描述符仅仅是用于监听是否有客户端发起连接,如果有就将这个连接请求交给服务端(监听功能由listen接口实现)。服务端真正与客户端进行通信的socket是accept所返回文件描述符。
所以,tcp协议中客户端与服务端的连接过程就可以看做是,客户端向服务端发起连接请求,该请求被由listen接口设置的监听socket接收,该监听socket将接收到的连接请求交给accept接口,accept接口中服务端判断是否建立连接。同意建立连接后就申请一个新的socket用于服务端与该客户端进行通信的渠道。而原来的监听socket重新回到监听状态。当然,实际的网络连接过程并不是这么简单,其中还涉及tcp三次握手等问题,这里只是将其简化后的过程。这里关于accept的说法其实是有问题的,因为在这个过程中还有tcp三次握手,但大家并不知道什么是tcp三次握手,所以在当前就可以这么理解。
3. 发起连接
在tcp中,客户端要向服务端发起请求,就要使用connect接口:
第一个参数sockfd是指客户端要通过哪个socket向服务端发送数据,该参数填socket接口的返回值即可。
第二个参数addr是一个结构体指针,里面保存了该客户端要向哪个服务端ip下的哪个端口发起连接请求等属性。
第三个参数addrlen是sockaddr结构体的大小。
二、实现一个简单的tcp程序
该程序要实现两个目标,即客户端向服务端发送数据和服务端向客户端返回数据。
1. 日志函数
大家要应该听过“日志”。日志就是用来记录程序的在运行过程中出现的各种状态的,例如正常运行、程序出现错误、程序出现异常、程序调试等等。
在日志中是划分了等级来对应不同事件的安全问题并进行不同的处理。例如当程序出现警告时,说明程序里面有些地方是不安全的,但是这个事件并不会程序无法运行。而出现错误时,就是程序内部有问题,这些问题会导致程序出现严重问题无法正常运行。在日志中,就需要通过等级划分来记录不同的错误。
这里为了先实现一个简单的tcp程序,所以就提供一个简单的日志函数,该函数会将日志信息打印到显示器上,以此来表示该程序的某个环节是否正常:
在该函数中,原本应该是要按照“[日志等级] [时间戳/时间] [pid] [message]”的格式输出信息的,这里为了先使用起来,就先修改为这样,等后续再重新修改。
同时,在该日志函数中一共将日志信息分为了5个等级,这五个等级分别代表了程序运行时出现的不同情况。
2. 服务端文件
2.1 .hpp文件
(1)初始化
因为当前这个程序只是一个简单的tcp程序,所以它的写法上当前和udp程序的写法是差不多的,启动方式为“./程序名 port”,在服务端中不能绑定特定的ip地址,以免其他ip无法向服务端传输数据。
在udp程序中,都需要用socket接口创建套接字和用bind绑定套接字。但是在tcp程序中有一点不同,就是要调用listen接口使套接字进入监听状态。这一接口的作用在接口介绍中已经讲过,就不过多赘述。
void initServer()//初始化
{
//1. 创建socket套接字
_listensock = socket(AF_INET, SOCK_STREAM, 0);//SOCK_STREAM,字节流,是tcp协议的特性
if(_listensock < 0)//调用失败,结束
{
logMessage(FATAL, "create socket error");
exit(SOCKET_ERR);
}
logMessage(NORMAL, "create socket success");
//2. 绑定套接字
struct sockaddr_in local;//创建sockaddr_in时要包含<arpa/inet.h>头文件
memset(&local, 0, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(_port);
local.sin_addr.s_addr = INADDR_ANY;//INADDR_ANY用于支持任意ip的连接
int n = bind(_listensock, (struct sockaddr *)&local, sizeof(local));
if(n < 0)//bind调用失败
{
logMessage(FATAL, "bind socket error");
exit(BIND_ERR);
}
logMessage(NORMAL, "bind socket successs");
//3. 设置sockfd为监听状态
n = listen(_listensock, gbacklog);
if(n < 0)
{
logMessage(FATAL, "listen socket error");
exit(LISTEN_ERR);
}
logMessage(NORMAL, "listen socket success");
}
(2)启动函数
tcp程序的启动和udp直接就可以接收客户端数据不同,首先需要用accept接口选择是否接受客户端的连接请求以建立连接,然后才能开始接收数据。但是tcp协议的接收数据常用recv接口,其中有一个参数大家此时还难以理解,所以这里就使用文件操作的read接口来接收数据。
至于为什么可以用read接口,这是因为网络通信的本质其实就是打开一个文件与网卡建立关系,在udp中网卡通过socket创建的套接字进行通信;在tcp中网卡通过accept接口创建的套接字进行通信。accept接口会创建套接字的原因在接口介绍中已经说明了,这里就不再过多赘述。
void start()//启动服务器
{
while(true)
{
//1. 建立连接。服务端与客户端通信的socket是accept返回的socket,而不是socket接口的返回值
struct sockaddr_in client;
memset(&client, 0, sizeof(client));
socklen_t clientlen = sizeof(client);
int sock = accept(_listensock, (struct sockaddr *)&client, &clientlen);
if(sock < 0)//建立连接失败
{
logMessage(ERROR, "accept errror, next");
continue;
}
logMessage(NORMAL, "accept a new link success");
//2. 读写客户端传过来的数据。由于tcp协议中是面向字节流的,所以tcp协议中的读写操作也可以使用文件系统的读写接口
serviceIO(sock);//提供读写数据功能
close(sock);//客户端退出,关闭对应文件描述符。因为一个服务端可能有多个客户端同时连接,当一个客户端退出后,要及时
//关闭它所使用的文件描述符,以让其他客户端使用,避免该文件描述符一直被占据导致文件描述符泄漏。
}
}
void serviceIO(const int &sock)
{
char buffer[1024];
while(true)
{
ssize_t n = read(sock, buffer, sizeof(buffer) - 1);
if(n > 0)//读取成功
{
//1. 读取数据
buffer[n] = 0;//设置\0,表示结尾
std::cout << "recv message: " << buffer << std::endl;
//2. 返回数据
std::string outbuffer = buffer;
outbuffer += " server[echo]";
write(sock, outbuffer.c_str(), outbuffer.size());
}
else if(n == 0)//n为0,在网络中说明客户端关闭
{
logMessage(NORMAL, "cient quit, me too");
break;
}
}
}
(3)总体结构
#pragma once
#include "log.hpp"
#include <iostream>
#include <string>
#include <string.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <unistd.h>
namespace server
{
enum
{
USAGE_ERR = 1,
SOCKET_ERR,
BIND_ERR,
LISTEN_ERR
};
static const uint16_t gport = 8080;
static const int gbacklog = 5;
class tcpServer
{
public:
tcpServer(const uint16_t &port = gport)
:_listensock(-1), _port(port)
{}
void initServer()//初始化
{
//1. 创建socket套接字
_listensock = socket(AF_INET, SOCK_STREAM, 0);//SOCK_STREAM,字节流,是tcp协议的特性
if(_listensock < 0)//调用失败,结束
{
logMessage(FATAL, "create socket error");
exit(SOCKET_ERR);
}
logMessage(NORMAL, "create socket success");
//2. 绑定套接字
struct sockaddr_in local;//创建sockaddr_in时要包含<arpa/inet.h>头文件
memset(&local, 0, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(_port);
local.sin_addr.s_addr = INADDR_ANY;//INADDR_ANY用于支持任意ip的连接
int n = bind(_listensock, (struct sockaddr *)&local, sizeof(local));
if(n < 0)//bind调用失败
{
logMessage(FATAL, "bind socket error");
exit(BIND_ERR);
}
logMessage(NORMAL, "bind socket successs");
//3. 设置sockfd为监听状态
n = listen(_listensock, gbacklog);
if(n < 0)
{
logMessage(FATAL, "listen socket error");
exit(LISTEN_ERR);
}
logMessage(NORMAL, "listen socket success");
}
void start()//启动服务器
{
while(true)
{
//1. 建立连接。服务端与客户端通信的socket是accept返回的socket,而不是socket接口的返回值
struct sockaddr_in client;
memset(&client, 0, sizeof(client));
socklen_t clientlen = sizeof(client);
int sock = accept(_listensock, (struct sockaddr *)&client, &clientlen);
if(sock < 0)//建立连接失败
{
logMessage(ERROR, "accept errror, next");
continue;
}
logMessage(NORMAL, "accept a new link success");
//2. 读写客户端传过来的数据。由于tcp协议中是面向字节流的,所以tcp协议中的读写操作也可以使用文件系统的读写接口
serviceIO(sock);//提供读写数据功能
close(sock);//客户端退出,关闭对应文件描述符。因为一个服务端可能有多个客户端同时连接,当一个客户端退出后,要及时
//关闭它所使用的文件描述符,以让其他客户端使用,避免该文件描述符一直被占据导致文件描述符泄漏。
}
}
void serviceIO(const int &sock)
{
char buffer[1024];
while(true)
{
ssize_t n = read(sock, buffer, sizeof(buffer) - 1);
if(n > 0)//读取成功
{
//1. 读取数据
buffer[n] = 0;//设置\0,表示结尾
std::cout << "recv message: " << buffer << std::endl;
//2. 返回数据
std::string outbuffer = buffer;
outbuffer += " server[echo]";
write(sock, outbuffer.c_str(), outbuffer.size());
}
else if(n == 0)//n为0,在网络中说明客户端关闭
{
logMessage(NORMAL, "cient quit, me too");
break;
}
}
}
~tcpServer()
{}
private:
int _listensock;//socket返回的文件描述符,仅用于监听是否有客户端的连接请求
uint16_t _port;//启动端口号
};
}
2.2 .cpp文件
tcp的主函数和udp主函数就没有任何差别:
#include "tcpServer.hpp"
#include <memory>
using namespace server;
void Usage(std::string proc)//告诉用户使用方法
{
std::cout << "\nUsage:\n\t" << proc << " local_port\n\n";
}
//启动方式: ./tcpServer port
int main(int argc, char *argv[])
{
if(argc != 2)//启动方式错误
{
Usage(argv[0]);
exit(USAGE_ERR);
}
uint16_t serverport = atoi(argv[1]);//获取启动端口
std::unique_ptr<tcpServer> tsvr(new tcpServer(serverport));
tsvr->initServer();//初始化服务器
tsvr->start();//启动服务器
return 0;
}
3. 客户端文件
3.1 .hpp文件
(1)启动函数
在当前,tcp程序的客户端中初始化起始和udp程序是一样的,都需要调用socket,无需调用bind,以避免在通信是出现端口冲突。所以这里不过多赘述。
但是启动函数中则有所差别。首先,tcp协议的一个特性是有连接,所以客户端需要通过connect接口向服务端发起连接请求,当请求被接受后才可以进行通信。这里也不使用tcp的send接口发送数据,而是用文件系统的接口write,原因不再过多赘述。
void start()
{
//1. 向服务端发起连接请求
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());
int n = connect(_sock, (struct sockaddr *)&server, sizeof(server));
if(n < 0)//连接失败
std::cerr << "socket connect error" << std::endl;
else//连接成功
{
std::string msg;
while(true)
{
//1. 写入数据
std::cout << "Please Enter# ";
std::getline(std::cin, msg);
write(_sock, msg.c_str(), msg.size());
//2. 读取数据
char buffer[NUM];
ssize_t n = recv(_sock, buffer, sizeof(buffer), 0);
if(n > 0)//读取成功
{
buffer[n] = 0;
std::cout << "tcpServer回显: " << buffer << std::endl;
}
else
break;
}
}
}
(2)总体结构
#pragma once
#include <iostream>
#include <string>
#include <string.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <unistd.h>
namespace client
{
#define NUM 1024
enum
{
USAGE_ERR = 1,
SOCKET_ERR
};
class tcpClient
{
public:
tcpClient(std::string &serverip, uint16_t &serverport)
:_sock(-1), _serverip(serverip), _serverport(serverport)
{}
void initClient()
{
//1. 创建socket套接字
_sock = socket(AF_INET, SOCK_STREAM, 0);
if(_sock < 0)
{
std::cerr << "create socket error" << std::endl;
exit(SOCKET_ERR);
}
//2. 绑定套接字。客户端中无需自己绑定,交由OS随机生成
}
void start()
{
//1. 向服务端发起连接请求
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());
int n = connect(_sock, (struct sockaddr *)&server, sizeof(server));
if(n < 0)//连接失败
std::cerr << "socket connect error" << std::endl;
else//连接成功
{
std::string msg;
while(true)
{
//1. 写入数据
std::cout << "Please Enter# ";
std::getline(std::cin, msg);
write(_sock, msg.c_str(), msg.size());
//2. 读取数据
char buffer[NUM];
ssize_t n = recv(_sock, buffer, sizeof(buffer), 0);
if(n > 0)//读取成功
{
buffer[n] = 0;
std::cout << "tcpServer回显: " << buffer << std::endl;
}
else
break;
}
}
}
~tcpClient()
{
if(_sock >= 0)
close(_sock);//关闭文件描述符。这个操作可做可不做,因为当客户端关闭后,该程序使用的资源都会被一并回收
}
private:
int _sock;//socket接口创建的套接字
std::string _serverip;//向服务端ip发送数据
uint16_t _serverport;//该服务端下的某个ip下的端口号发送数据
};
}
3.2 .cpp文件
#include "tcpClient.hpp"
#include <memory>
using namespace client;
void Usage(std::string proc)//告诉用户使用方法
{
std::cout << "\nUsage:\n\t" << proc << " server_ip server_port\n\n";
}
//使用方法:./tcpClient severip serverport
int main(int argc, char *argv[])
{
if(argc != 3)
{
Usage(argv[0]);
exit(USAGE_ERR);
}
std::string serverip = argv[1];
uint16_t serverport = atoi(argv[2]);
std::unique_ptr<tcpClient> tcli(new tcpClient(serverip, serverport));
tcli->initClient();
tcli->start();
return 0;
}
4. 程序测试
当程序完成后,就可以开始测试了。如果大家没有两台linux机器,就使用127.0.0.1本地环回测试。
程序可以正常运行。
三、实现支持多个用户并发访问的tcp程序
1. 当前程序问题
在上面的程序中,虽然可以进行通信,但是它只支持单用户通信,即串行通信。原因是在tcp协议中,是需要建立连接的,所以程序在服务端的启动程序中就需要用accept接口接受连接请求。
但是,在上面的程序中,服务端启动后,就会死循环读取客户端的数据,如果此时有其他用户发起连接,由于客户端在serviceIO函数中死循环运行,无法跳转到accept接口处处理连接请求,这就导致客户端的连接请求被挂起,客户端的数据无法发送到服务端,只有当客户端死循环处理的客户端退出后,服务端才会退出serviceIO函数跳转到accept处处理连接请求。
为了证明在当前客户端未退出时,服务端不会处理其他客户端的连接请求,只有当该服务端退出后才会处理,所以再打开一个会话窗口发起连接:
可以看到,此时有两个客户端向服务端发起连接并发送数据,但是只有先启动的客户端可以进行通信,后启动的客户端无法通信。此时将客户端1退出:
当客户端1退出后,服务端就可以与客户端2建立连接并正常通信了。且在客户端1未退出时客户端2发送的数据也被接收并返回了。
2. 多进程版程序
由于tcp协议的特性,所以无法像udp那样一个进程就可以进行多用户通信。在这里为了让该程序支持多用户通信,第一个方案就是使用多进程。让父进程从accept接口中获取连接,然后让子进程去处理客户端请求,以此实现多用户。
但是多进程中有一个问题,那就是子进程的资源回收问题。当子进程去执行程序后,需要让父进程去等待子进程退出以回收资源,避免资源泄漏。但是如果用waitpid去阻塞式等待子进程退出,就会导致父进程会在waitpid处被阻塞,导致父进程无法执行其他操作,也就无法跳转到accept处去处理后来的连接请求。
有人可能会说,既然阻塞等待不可行,那么非阻塞等待呢?其实也是不行的,因为在没有连接到来时,父进程其实是阻塞在accept处的。如果一个客户端到来后,父进程直接向下运行到waitpid处,因为是非阻塞等待,所以父进程就会循环往上阻塞在accept处。如果此时一直没有新连接到来,并且子进程所处理的客户端退出了,就会导致子进程也退出。但是父进程此时在accept处阻塞,无法跳转到waitpid,此时就会导致该子进程无法被父进程回收进而成为僵尸进程,造成资源泄露。
2.1 创建孙子进程
要解决这个问题,有很多方案,这里就简单介绍两种方案。第一种就是在子进程中再创建一个子进程,然后退出子进程,让孙子进程去处理客户端。此时对于孙子进程而言,它的父进程退出,但是它本身还没有退出,因此它变为孤儿进程。而孤儿进程会由OS接收,在孤儿进程退出时,OS会自动回收它的资源。通过让孤儿进程处理客户端的方式,就让父进程无需关心孙子进程的情况,只需回收它的子进程资源即可。
在start函数中添加如上代码即可实现多用户通信。
这里还有两个问题要注意:
(1)因为子进程会拷贝一份父进程的文件描述符,所以在子进程中将监听关闭,以避免在子进程中错误使用。
(2)因为通信的工作交给子进程去执行,父进程只需要负责获取连接的原因,父进程在让子进程拷贝走用于通信的文件描述符后,就不需要使用这些文件描述符了。所以父进程就需要将这些文件描述符关闭。如果不关闭,那么父进程中无用的文件描述符越积越多,最终就会导致父进程的可用文件描述符被用完,导致无法接受连接。
由此,可以开始测试了:
可以看到,此时就完成了多用户通信。
2.2 信号捕获
当然,这种做法虽然可行,但是并不太好,因为如果遇到大量客户端访问的时候,它就需要频繁的创建和关闭进程,拉低运行效率。
因此可以采取第二种方案,即“信号捕获”。大家知道,传给一个进程的信号是可以被捕获的,而子进程退出时会向父进程发送SIGCHCHID信号,告诉父进程子进程已经退出。
因此当子进程退出时,我们可以用signal接口捕获它的退出信号,然后让该信号不执行默认动作,而是去执行我们设置的自定义动作,然后在该函数执行的函数中回收子进程资源即可。该方案就无需在子进程中创建孙子进程并关闭子进程了。同时父进程也就不再需要等待回收子进程资源,因为这一动作交由信号捕捉中所设置的函数执行了。
如果想简单点,就可以在signal接口的第二个参数中传入SIG_IGN信号,让进程忽略SIG_CHID信号,让子进程自己退出即可,父进程无需对该信号进行处理。
3. 多线程版程序
虽然多进程可以解决多用户通信的问题,但是一旦用户过多,那么服务端就会创建大量的进程,这对OS而言无疑是一个重负。同时我们可以知道,在网络通信中,其实子进程执行任务所需要的资源都是从父进程中拿过来的,所以我们可以用多线程来实现同样的功能。
线程其实就是共享进程的资源,所以要让线程去执行不同的任务也是很简单的,无需像多进程那样需要考虑进程间通信的问题。
要实现多进程版本也是比较简单的,首先创建线程,然后让该线程去执行对应的启动函数即可。因为线程的启动函数严格要求参数为void*,所以要将启动函数声明为static,以去掉类中的隐含this指针。然后通过传一个对象指针到启动函数中,让该启动函数获取它所需要执行的任务和所需的资源。
当然,线程中也是存在线程资源回收问题的。解决方案很简单,无需调用pthread_join让主线程等待回收线程资源,而是直接用pthread_detach接口让线程分离,使从线程自行释放资源。
添加如上内容,就可以将线程修改为多进程版。运行该程序进行测试:
同样可以正常运行。
4. 线程池版程序
虽然多进程和多线程都挺好,都可以正常运行。但是它们都存在同一个问题,那就是需要频繁的创建和销毁进程或线程。假设在某个时间段内有大量的用户涌入和离开服务器,此时就会导致服务端需要频繁的创建和销毁线程或进程以供客户端使用。这无疑会对服务端造成巨大的压力。为了缓解这个问题,就可以采用线程池或进程池的方案。线程池和进程池虽然实现上不同,但是都是池化,本质上是一样的。这里就只以线程池为例。
线程池家应该都了解过。线程池说白了就是提前准备好一批线程,当需要的时候,就从线程池中拿走一个去执行特定的任务,任务执行完后就将这个线程重新放到线程池里。在我的的文章“初识linux之线程池”中就实现了一个简单的线程池,如果大家需要,可以去该文章下直接复制即可。这里就不再重复写,而是直接使用该线程池文件了。
实现起来也非常简单,直接调用线程池,然后修改创建一个新的任务类,让这个类去执行对应的任务即可。
修改完启动函数后,就写一个新的类:
上面的操作完成后,运行程序进行测试:
程序运行正常。但是要注意,上面的这个程序准确来说是不适合用线程池的。因为这个程序中从线程执行任务时是死循环执行的,只要客户端不退出,线程就无法回到线程池,最终就会导致线程池内的线程被用完,需要重新创建。
因此,线程池其实是适合于那种任务能够迅速完成,让线程的复用率提高的场景。
四、修改日志函数
在一开始的时候,就说了服务器中应该有日志函数,能够将程序运行的日志信息保存起来。但是在开始的并没有过多讲解。在这里,就要改造日志函数,让它能够根据我们规定的格式输出。
1. 可变参数列表
在修改日志函数之前,先来了解一下可变参数列表。可变参数列表与c++中的可变参数模板是不同,不要混淆。
在c中,可变参数模板其实我们一直都在使用,例如printf函数:
在这些函数中,它后面的"..."其实就是可变参数列表,代表可以传0~n个参数。既然可以传多个参数,就涉及到了编译器如何识别这些参数,即如何解包。
1.1 可变参数列表的解包
在可变参数列表中,要获取可变参数列表中的参数,就需要使用到四个东西。
分别是va_list、va_start、va_arg和va_end。
以printf函数举例来解释它们。假设现在有如下一个printf打印:
从上面的printf中的参数列表可知,它后面的1.1、2和''A'其实都属于可变列表中传递的参数。那么printf是如何将这些参数填入字符串中的呢?
首先是va_list。va_list我们可以将其看做是一个指针,这个指针需要指向可变参数列表的第一个参数。当它想指向第二个参数的时候,它需要根据类型,即%后面带的字符,用起始地址加上偏移量,当然其中还涉及内存对齐的等问题,进而找到下一个参数的位置并指向它。
指针有了之后,如何让这个指针指向可变参数列表中的第一个参数呢?此时就需要用va_start了。va_start是一个宏,通过使用va_start,就可以将指针指向对应的可变参数列表的第一个参数。
指向第一个参数的问题解决后,就是这个指针如何移动了。此时就需要使用va_arg。这个宏可以让指针向前移动指定类型大小的偏移量:
例如上面图中的va_arg就是让指针向前移动int类型,即4字节。
最后是va_end。这个宏的作用很简单,就是将指针置为空。
以pirntf函数举例,在处理printf的可变参数列表时,就可以看成以"%"为分隔符,当遇到%时,就进入case语句,让指针向前走指向%的下一个字符,根据这个字符来判断它的类型,然后让指向可变参数列表的指针向前走该类型大小步,将对应的值提取出来并替换%和它后面的类型。
上图中只是一个伪代码,实际处理过程是很复杂的,这里只是为了展示一下大致逻辑才这样写。
1.2 vsnprintf函数
vsnprintf函数接口就支持自己指定字符串对其按照特定格式进行初始化。
第一个参数str,是你要将数据输出到的缓冲区的位置;第二个参数size是该缓冲区的大小。
第三个参数format是要格式化的字符串;第四个参数ap则是参数的起始位置的指针。
2. 时间转换
在这个日志函数中,我们需要输出它形成的时间。要获得时间,可以调用time接口:
time接口的作用是获取当前时间的时间戳。填入nullptr默认获取当前时间的时间戳。如果想将时间戳转换为具体的时间,就还需要使用local_time接口:
第一个参数就是要转换的时间戳。它会将传入的时间戳转换为特定的时间填入tm结构体中并将其返回。该结构体的成员变量如下:
通过获取该函数的成员变量,就可以得到想要的时间。
当然,如果你不想自己控制格式,就可以使用ctime接口:
该接口会将传入的时间戳按照特定格式形成字符串并返回。虽然很方便,但用户自己是无法控制打印的格式的。
3. 形成一个简单的日志函数
有了上面的知识后,就可以将日志函数转化为如下所示:
为了方便测试,该日志函数所打印的信息是直接输入到显示器上的。将线程池和tcp服务端的打印信息换成用日志函数输出:
上图是替换后的日志函数,分别表示传参和不传参的状态。还有其他使用了日志函数的语句未写出来。运行程序:
此时日志信息就如我们所想的输出出来了。
这里是将文件输出到了显示器中。但实际上,这些信息应该存放对应的日志文件中。实现方式也很简单,就是使用文件操作的接口将字符串输出到文件中即可。
这里就准备两个文件,一个是normal.txt文件,存储DEBUG、NORMAL和WARNING错误。而error.txt则存储ERROR和FATAL错误。
#pragma once
#include <iostream>
#include <string>
#include <stdio.h>
#include <time.h>
#include <stdarg.h>
#include <sys/types.h>
#include <unistd.h>
#define LOG_NORMAL "normal.txt"
#define LOG_ERROR "error.txt"
#define DEBUG 0//调试
#define NORMAL 1//正常运行
#define WARNING 2//警告
#define ERROR 3//出错,但不影响程序正常运行
#define FATAL 4//出错,会影响程序运行,终止程序
const char *to_levelstr(int level)//将等级转换为字符串
{
switch(level)
{
case DEBUG : return "DEBUG";
case NORMAL : return "NORMAL";
case WARNING : return "WARNING";
case ERROR : return "ERROR";
case FATAL : return "FATAL";
}
return nullptr;
}
void logMessage(int level, const char *format, ...)
{
//[日志等级] [时间戳/时间] [pid] [message]
//[warning] [2023.5.12] [123] [socket调用失败]
#define NUM 1024
char logprefix[NUM];
std::string ti;//获取时间
time_t t = time(nullptr);//获取时间戳
char *ch = ctime(&t);
std::string str = ch;
str[str.size() - 1] = '\0';//去掉最后的换行
snprintf(logprefix, sizeof(logprefix), "[%s][%s][%d]", to_levelstr(level), str.c_str(), getpid());
char logcontent[NUM];
va_list arg;//提供可初始化列表的指针
va_start(arg, format);//是初始化列表的指针指向第一个参数
vsnprintf(logcontent, sizeof(logcontent), format, arg);//将格式化后的字符串填入logcontent中
FILE* normal = fopen(LOG_NORMAL, "a");
FILE* error = fopen(LOG_ERROR, "a");
FILE* cur = nullptr;
if(level == DEBUG || level == NORMAL || level == WARNING) {cur = normal;}
else if(level == ERROR || level == FATAL) {cur = error;}
if(cur) {fprintf(cur, "%s%s\n", logprefix, logcontent);}//将数据写入对应的文件中
}
完成后运行程序:
可以看到,输出内容正常传输到了设置的文件里面。
五、服务端后台化
在上面的程序中,我们写了一个简单的tcp程序用于客户端和服务端的数据交换。运行起来也很正常。但是这有一个问题,那就是当服务端运行的时候,如果我们关闭对应的窗口会怎么样?
在xshell上打开两个窗口,一个窗口运行服务端,另一个窗口执行“ps -aL | grep 程序名”命令,查看当前正在运行的该名字的线程:
通过另一个窗口可以看到,当前有6个线程。该程序使用的线程池中创建了5个线程,加上主线程就是6个线程,没有问题。此时关闭服务端的会话窗口,再执行该命令:
此时没有该名字的线程在运行。这也就说明,当我们把会话窗口关闭后,服务端也会自动退出。这个结论就说明,当我们需要运行服务端的时候,就必须保证该会话窗口一直开启,如果不想让它退出,我们就只能傻傻的将它一直打开。
但是,如果我们想在保持客户端一直运行的状态下, 关闭这个会话窗口,乃至关闭xshell,都让这个客户端一直运行不退出呢?实现方案其实就是将进程修改为“守护进程”。
1. 守护进程
1.1 前台进程与后台进程
在理解守护进程之前,先来了解一下程序的前台进程与后台进程。以xshell中的进程为例。在我们启动xshell后,就会启动一个bash,暂且将这个bash所打开的窗口叫做一个会话。在这个会话中,有且仅有一个前台任务,但可以有0~n个后台任务。这些任务全部都属于一个会话:
那么如何创建后台任务呢?在要执行的程序后面带上“&”即可:
此时就形成了一个后台程序。我们再多创建几个后台程序:
此处我们用sleep命令来模拟执行程序。它也会被当做一个进程在跑。当我们创建完这些程序后,再运行“jobs”命令:
在这里可以看到如上内容。这里“./tcpServer 8082 &”、“sleep1000 | sleep 4000”都被叫做“作业”。即这些进程共同要完成的一项任务。
再运行“ps ajx | head -n1 && ps ajx | grep sleep”查看当前正在运行的sleep进程:
可以看到,此时就有多个sleep进程在运行。大家如果仔细观察后就会发现,这里除了pid和ppid外,还有pgid和sid两个id。那么这两个id是什么意思呢?先来看pgid。
pgid其实就是“组id”。标志这些程序在同一个组中,通力合作完成同一个任务。通过观察可以发现,上面的组id中,有一些进程的组id其实是一样的:
拥有相同pgid的进程,就是属于同一个组的。每个组中拥有一个组长,组长的pid和pgid是一样的。组的概念其实是比较好理解的。例如现实生活中的,大家都应该经历过小组作业。在小组作业中,每个小组都有一个组长,组长负责管理这个小组,小组中每个成员的任务都不相同,有人可能负责做ppt,有人可能负责收集资料。虽然组员的任务不同,但是所有的组员的工作其实都是为了完成老师布置的作业这一个任务而形成的。这里的组也是一样的。属于同一个组的线程的任务虽然不同,但它们其实都是为了完成同一个目标而执行任务。
至于sid,其实就是标志这些进程属于哪一个会话。sid相同的进程就属于同一个会话,反之属于不同会话。而这里的这个sid,其实就是bash:
1.2 前台进程与后台进程切换
在linux启动后,其实就默认启动了一个前台进程bash。在上面的实验中,我们准备好了3个后台作业。运行jobs命令:
可以看到在最前面都有一个编号,这个编号其实就是作业的编号。我们可以通过“fg 编号”命令来将一个作业切换到前台。当一个作业被切换到前台后,此时就只能让这个作业运行,我们无法执行其他作业的操作。
此时可以按下“ctrl z”组合键,暂停该作业。此时bash又会重新回到前台,可以执行其他操作:
此时该作业就被暂停了。如果想重新启动这个作业,可以输入“bg 编号”来让该作业继续在后台运行:
通过上面的实验,大家应该就知道如何切换前台作业和后台作业了。通过在执行了2号作业时,bash就被切到了后台,此时我们无法使用bash中的命令的实验,也证明了前台任务在同时只能存在一个。这些后台作业在后台中其实也是在不断运行的。
1.3 守护进程的概念
对于这些由多个进程组成的一个会话窗口,就可能受到某些进程退出的影响。例如在上面的那个会话窗口中,一旦bash进程退出,那么在这个进程之内的其他进程也会随之退出。无疑,这种进程是无法满足服务端持续运行不退出的需求的。
既然由多个进程组成的会话可能被其中的某些进程所影响,那我们只需要让某个进程单独形成一个会话窗口,就可以保证该进程不受其他任何进程的影响,只受运行它的计算机是否启动的影响。这种进程,其实就叫做“守护进程”。
2. 服务端守护进程化
前文说了,当前我们写的服务端在bash关闭后就会自动退出,无法满足我们所需要的让服务端不受xshell终端是否开启的影响而持续运行。此时就可以将这个服务端“守护进程化”来实现持续运行。
2.1 daemon接口
要让进程守护进程化有很多种方式,其中一种就是直接调用系统接口daemon:
这个接口可以直接将一个进程守护进程化。但是并不推荐使用这个接口,而是自己实现一个简单的守护进程化代码。因为这个接口中存在很多未定义行为,在使用时可能会出现问题。而我们自己实现一个简单的守护进程化代码,可以更好的针对不同情况进行定制化处理。
2.2 setsid接口
进程守护进程化的另一种方式,就是调用setsid:
该接口会自建一个会话并创建一个组,组id就是运行该接口的进程pid。然后将运行该接口的进程放入这个新建的会话的组中。
但是这个接口并不能够随意调用,调用该接口的进程,不能是其他组的组长。
2.3 实现一个简单的守护进程化程序
要自行实现一个简单的守护进程化程序,就可以使用setsid接口。但是在使用这个接口之前,还有一些工作要做。
首先,在这个调用进程中,我们要让它忽略掉一些异常的信号。因为在某些情况下,这个调用进程可能会收到OS给它发送的某些信号而导致它被终止。例如客户端在向服务端发送数据后,服务端要将某些数据返回给客户端,但就在服务端准备向客户端返回数据时,客户端崩溃或者关闭了,此时就会导致服务端与客户端通信用的文件描述符被关闭,如果服务端继续向该文件描述符写数据,就是向一个已经被关闭的文件描述符写数据,此时服务端就会收到SIGPIPE信号,该信号就会将服务端终止。无疑,这并不是我们想看见的情况,所以在将服务端守护进程化时要忽略掉某些异常信号。
同时大家知道,setsid接口是有调用前提的,调用该接口的进程不能是组长。那么如何保证一个进程不是组长呢?很简单,因为一般来讲,在一个组中的第一个进程其实就是组长。因此,可以创建一个子进程,然后将父进程关闭,此时该子进程会变为孤儿进程,被OS接收。因此,守护进程有时也叫精灵进程,其实它就是孤儿进程的一种。
最后,当该进程被放进一个新的会话和组内后,因为守护进程是与终端相分离的,而当前该进程的0、1、2三个文件描述符默认打开了标准输入、标准输出和标准错误,与终端相连。因此我们还要将这三个文件描述符关闭或者重定向。
这里一般推荐重定向,因为关闭文件描述符的话,如果你的程序里面某些地方没有处理干净,还在使用这三个文件描述符,就会导致进程向已关闭文件描述符写数据,出现错误。因此最好是将它们重定向。那么重定向为哪个文件描述符呢?这里就比较推荐“/dev/null”文件。该文件大家可以看成一个粉碎机,任何向该文件写入的数据都会被其自动丢弃。
还有一个可选方案,那就是更改进程的工作路径。一般我们的程序启动后,它默认的工作路径都是该程序所处的路径。如果你想更改进程的工作路径,可以使用chdir:
有了上面的知识,就可以写出一 个比较简单的守护进程化程序了:
#pragma once
#include <iostream>
#include <signal.h>
#include <unistd.h>
#include <assert.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#define DEV "/dev/null"
void deamonSelf(const char *currPath = nullptr)
{
//1. 忽略某些异常信号
signal(SIGPIPE, SIG_IGN);
//2. 保证进程不是组长
if(fork() > 0) exit(0);
pid_t id = setsid();
assert(id != -1);
//3. 守护进程是脱离终端的。关闭或者重定向进程默认打开的文件描述符
int fd = open(DEV, O_RDWR);//以读写方式打开
if(fd > 0)
{
dup2(fd, 0);
dup2(fd, 1);
dup2(fd, 2);
close(fd);
}
else//文件打开失败,直接关闭文件描述符
{
close(0);
close(1);
close(2);
}
//4. 更改进程的工作路径
if(currPath)
chdir(currPath);
}
有了守护进程化的代码后,就可以在服务端调用该接口了。
此时再运行服务端:
可以看到,此时该客户端就是一个守护进程,不同于以前我们运行客户端后,打开的会话窗口就无法执行其他操作,在这里我们运行客户端后,依然可以执行bash的其他操作。
当然,也是可以正常通信的。启动另一个窗口,启动客户端与服务端通信:
依然可以正常通信。
六、简单理解tcp三次握手和四次挥手
1. tcp协议的客户端和服务端通信一般流程
在tcp协议的程序中要进行通信前,服务端和客户端都需要做一些准备工作。在服务端中,首先要调用socket接口分配文件描述符,然后调用bind接口绑定,绑定完成后调用listen接口,让socket分配的那个文件描述符成为一个监听文件描述符,最后调用accept接口阻塞式的获取用户连接;客户端中则是需要调用socket接口分配一个文件描述符,然后让OS随机bind一个端口,最后调用connect向服务端发起连接,并阻塞式的等待服务端的应答。
当一台主机向另一台主机发起连接的时候,就会进行tcp三次握手。注意,tcp三次握手用户是无法看见这一过程的。大家知道,tcp协议属于传输层,而传输层是属于OS的,这也就意味着,tcp三次握手的过程是由OS为我们完成的,无需用户关心。当客户端调用connet向另一台主机发起连接的时候,双方的OS就会自动进行三次握手。而accept接口我们将其称为获取连接,获取连接就意味着此时连接已经完成,这也就是说,accept根本不会参与到tcp三次握手中,而是获取已经完成了三次握手后可用的连接。
当连接完成后,双方才会开始调用read、write、send和recv等接口进行网络通信。
当连接不再被需要,断开连接时,就会进行tcp四次挥手。四次挥手也是tcp协议中的内容,这也就意味着四次挥手的过程也无需用户关心,是由OS为我们完成的。用户只决定的是什么时候断开连接,如何断开连接则由双方的OS来处理。
因此,在tcp程序中,一次完整的网络通信就可以看做:tcp三次握手—>通信—>tcp四次挥手。
2. 简单理解tcp三次握手
通过上面的tcp通信流程大家就应该知道,tcp三次握手其实就是双方的OS建立连接的过程。而tcp三次握手中就涉及到了tcp中为了保证可靠性而产生的“确认应答”机制。
当客户端通过connect向服务端发起连接时,客户端会先向服务端发送一份“同步报文段(SYN)”以表示请求建立连接;当服务端接收到“同步报文段”后,就会向客户端返回一份“同步报文段”和“确认报文段(ACK)”,用来告诉客户端此次服务端是否同意建立连接和服务端已经收到了客户端发送的同步报文段。当客户端接收到同步报文段和确认报文段后,就会向服务端发送一份“确认报文段”以告知服务端,客户端已经收到了它所发送的同步报文段和确认报文段。
上面的过程中,客户端向服务端发起连接请求,服务端返回信息确认是否连接,然后客户端向服务端发送收到该信息的确认消息的过程,就是“tcp三次握手” 。
这样看大家可能不是很好理解,举个例子,假设当你学校里面散步时,你在路上看到了一个美女并且对她一见钟情,而你又是一个胆子很大的人,于是你走到她面前,问她可不可以做你的女朋友。这个女生看见了你之后,发现你长得很帅,也对你一见钟情。于是她就说“我愿意做你的女朋友,我们什么时候开始交往呢?”你听到这个回答后很高兴啊,于是就说“从现在就开始交往吧”。在你回答之后,你们就成了男女朋友关系,也就是所谓的“建立连接”。
在上面的这个例子中,男生相当于“客户端”,女生相当于“服务端”,男生向女生发起交往请求的时候,就是客户端向服务端发起连接请求,是第一次握手;女生听到告白后回答说愿意并询问什么时候交往就是服务端向客户端返回信息,是第二次握手;而男生告诉女生现在开始交往,其实就是客户端返回信息给服务端告知服务端它收到了信息,开始建立联系,这就是第三次握手。通过三次握手后,就建立了双方的连接。只要这三次握手中有一次握手失败,都无法建立连接。
大家设想一下,假设你和你女朋友交往后,如果你的女朋友记性比较差,当你们刚宣布交往几分钟后,对方就因为记性很差把你给忘了,那你们的这次交往有意义吗?并没有。网络连接也是如此。当tcp三次握手成功后,双方都需要有一个记忆机制,将建立好的连接保存在某个区域以供后续使用。
但是如果有多台客户端向服务端发起连接并连接成功了呢?这就好比有一个渣男,他能说会道,同时和很多个女生交往,两三个还好,如果有十多个二十多个女生呢?他可能单凭脑子就记不住这些女生,而要将这些女生的信息用小本子记录起来,每个女生划一个模块,每个模块中都包含了该女生的姓名、年龄、爱好等内容。OS也是如此。当服务端中存在多个客户端连接时,为了防止数据丢失,也需要通过“先描述再组织”的方式,为这些连接建立结构体以保存它们的各类属性,然后通过链表或其他数据结构将其串联保存起来。因此,所谓的连接,其实就是在OS中创建出来的结构体。
就好比渣男需要用小本子将女生的信息保存起来,OS也是需要将连接的信息保存起来的。这就是说,创建连接时是会有时间成本和空间成本的。
3. 简单理解tcp四次挥手
tcp四次挥手,其实就是客户端和服务端断开连接的过程。当客户端要与服务端断开连接时,客户端会先向服务端发送一个“结束报文段(FIN)”,告诉服务端它现在要断开连接。服务端接收到客户端发送的结束报文段后,就会向客户端返回一个“确认报文段(ACK)”,告诉客户端它接收到这个消息了。然后服务端会再向客户端发送一个“结束报文段”告诉服务端它要与客户端断开连接。当客户端接收到这条结束报文段后,就会向服务端发送一条“确认报文段”告知服务端它收到了对应的消息。此时,客户端和服务端才是真正断开连接。
在上面的客户端向服务端发送断开连接的消息,服务端返回消息确认后服务端再向客户端发送一个断开连接的消息,客户端接收到后再向服务端返回消息确认收到后断开连接的过程, 就是“tcp四次挥手”。
举个例子方便大家理解。假设你有一个女朋友,本来你们的关系非常好,但是后来因为某些关系,你们之间关系迅速恶化。于是在某一天,你对你的女朋友说“我们分手吧”。你的女朋友听到后,就说“好呀”。说完后她又对你说“你要和我分手,我也要和你分手”。你听了后也回答到“好呀”。此时分手这个信息就被你们双方确认,在此之后,你们就真正分手了,断开联系了。
在上面的这个例子中,你对你的女朋友说分手,就是客户端告诉服务端它要断开连接;你的女朋友回你说好呀,就是服务端向客户端返回确认信息;你的女朋友随后说她也要和你分手,就是服务端向客户端发送断开连接的消息;而你回答说好呀,就是客户端向服务端返回确认消息。通过这四次挥手,就成功断开的连接。
至于为什么tcp中会有三次握手和四次挥手,这其实涉及到tcp协议的“通知应答”机制,这里就不再详谈。