单纯多线程存在的问题
当前多线程版的服务器存在的问题:
解决思路
针对这两个问题,对应的解决思路如下:
引入线程池
实际要解决这里的问题我们就需要在服务端引入线程池,因为线程池的存在就是为了避免处理短时间任务时创建与销毁线程的代价,此外,线程池还能够保证内核充分利用,防止过分调度。
其中在线程池里面有一个任务队列,当有新的任务到来的时候,就可以将任务Push到线程池当中,在线程池当中我们默认创建了5个线程,这些线程不断检测任务队列当中是否有任务,如果有任务就拿出任务,然后调用该任务对应的Run函数对该任务进行处理,如果线程池当中没有任务那么当前线程就会进入休眠状态。
在博主的另一篇博客当中详细介绍并实现了线程池,这里就直接将线程池的代码接入到当前的TCP服务器,因此下面只会讲解线程池接入的方法,如果对线程池的实现有疑问的可以去阅读那篇博客。
#define NUM 5//线程池
template
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);}//线程池中线程的执行例程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 ThreadPoolInit(){pthread_t tid;for (int i = 0; i < _thread_num; i++){pthread_create(&tid, nullptr, Routine, this); //注意参数传入this指针}}//往任务队列塞任务(主线程调用)void Push(const T& task){LockQueue();_task_queue.push(task);UnLockQueue();WakeUp();}//从任务队列获取任务(线程池中的线程调用)void Pop(T& task){task = _task_queue.front();_task_queue.pop();}private:std::queue _task_queue; //任务队列int _thread_num; //线程池中线程的数量pthread_mutex_t _mutex;pthread_cond_t _cond;
};
服务类新增线程池成员
现在服务端引入了线程池,因此在服务类当中需要新增一个指向线程池的指针成员:
现在当服务进程调用accept函数获取到一个连接请求后,就会根据该客户端的套接字、IP地址以及端口号构建出一个任务,然后调用线程池提供的Push接口将该任务塞入任务队列。
这实际也是一个生产者消费者模型,其中服务进程就作为了任务的生产者,而后端线程池当中的若干线程就不断从任务队列当中获取任务进行处理,它们承担的就是消费者的角色,其中生产者和消费者的交易场所就是线程池当中的任务队列。
class TcpServer
{
public:TcpServer(int port): _listen_sock(-1), _port(port), _tp(nullptr){}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;if (bind(_listen_sock, (struct sockaddr*)&local, sizeof(local)) < 0){std::cerr << "bind error" << std::endl;exit(3);}//监听if (listen(_listen_sock, BACKLOG) < 0){std::cerr << "listen error" << std::endl;exit(4);}_tp = new ThreadPool(); //构造线程池对象}void Start(){_tp->ThreadPoolInit(); //初始化线程池for (;;){//获取连接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;Task task(sock, client_ip, client_port); //构造任务_tp->Push(task); //将任务Push进任务队列}}
private:int _listen_sock; //监听套接字int _port; //端口号ThreadPool* _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){}~Task(){}//任务处理函数void Run(){_handler(_sock, _client_ip, _client_port); //调用仿函数}
private:int _sock; //套接字std::string _client_ip; //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 (true){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类当中对()的重载函数就行了,而服务器的初始化、启动服务器以及线程池的代码都是不需要更改的,这就叫做把通信功能和业务逻辑在软件上做解耦。
代码测试
此时我们再重新编译服务端代码,并用以下监控脚本查看服务端的各个线程。
while :; do ps -aL|head -1&&ps -aL|grep tcp_server;echo "####################";sleep 1;done
运行服务端后,就算没有客户端发来连接请求,此时在服务端就已经有了6个线程,其中有一个是接收新连接的服务线程,而其余的5个是线程池当中为客户端提供服务的线程。
此时当客户端连接服务器后,服务端的主线程就会获取该客户端的连接请求,并将其封装为一个任务对象后塞入任务队列,此时线程池中的5个线程就会有一个线程从任务队列当中获取到该任务,并执行该任务的处理函数为客户端提供服务。

当第二个客户端发起连接请求时,服务端也会将其封装为一个任务类塞到任务队列,然后线程池当中的线程再从任务队列当中获取到该任务进行处理,此时也是不同的执行流为这两个客户端提供的服务,因此这两个客户端也是能够同时享受服务的。

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