多进程与多线程对比
多进程
优点
- 可以处理多个用户
- 易于边写
- 稳定,因为进程具有独立性
缺点
- 连接来了之后才创建进程,性能太低
- 多进程服务器特别吃资源,而且同时服务的客户有上限,上限也很容易达到
- 进程越多,CPU在调度时选择一个进程的周期会变长,客户等待的时间就变长。也就是切换成本大,影响性能。
多线程
多线程版本的程序同样也有多进程版本的几个缺点,但是相对于进程来说,创建线程的代价要小很多,而且调度线程比调度进程的粒度要小,这样就可以降低成本,提高性能。
但是线程还有一个缺点就是线程不稳定,一个线程的退出会导致主线程直接退出。
总结一下就是,进程的开销要远远大于线程,所以,如果需要同时为较多的客户机提供服务,则不推荐使用多进程;如果单个服务执行体需要消耗较多的CPU资源,比如需要进行大规模或长时间的数据运算或文件访问,则进程较为安全。
不论是多进程还是多线程都是来了连接请求之后才创建进程或线程,这个问题我们可以用进程池和线程池来解决。
多线程版的TCP网络程序
当服务进程调用accept函数获取到一个新连接后,就可以直接创建一个线程,让该线程为对应客户端提供服务。
当然,主线程(服务进程)创建出新线程后,也是需要等待新线程退出的,否则也会造成类似于僵尸进程这样的问题。但对于线程来说,如果不想让主线程等待新线程退出,可以让创建出来的新线程调用pthread_detach函数进行线程分离,当这个线程退出时系统会自动回收该线程所对应的资源。此时主线程(服务进程)就可以继续调用accept函数获取新连接,而让新线程去服务对应的客户端。
各个线程共享同一张文件描述符表
文件描述符表维护的是进程与文件之间的对应关系,因此一个进程对应一张文件描述符表。而主线程创建出来的新线程依旧属于这个进程,因此创建线程时并不会为该线程创建独立的文件描述符表,所有的线程看到的都是同一张文件描述符表。
因此当服务进程(主线程)调用accept函数获取到一个文件描述符后,其他创建的新线程是能够直接访问这个文件描述符的。
需要注意的是,虽然新线程能够直接访问主线程accept上来的文件描述符,但此时新线程并不知道它所服务的客户端对应的是哪一个文件描述符,因此主线程创建新线程后需要告诉新线程对应应该访问的文件描述符的值,也就是告诉每个新线程在服务客户端时,应该对哪一个套接字进行操作。
参数结构体
实际新线程在为客户端提供服务时就是调用Service函数,而调用Service函数时是需要传入三个参数的,分别是客户端对应的套接字、IP地址和端口号。因此主线程创建新线程时需要给新线程传入三个参数,而实际在调用pthread_create函数创建新线程时,只能传入一个类型为void*的参数。
这时我们可以设计一个参数结构体Param,此时这三个参数就可以放到Param结构体当中,当主线程创建新线程时就可以定义一个Param对象,将客户端对应的套接字、IP地址和端口号设计进这个Param对象当中,然后将Param对象的地址作为新线程执行例程的参数进行传入。
此时新线程在执行例程当中再将这个void*类型的参数强转为Param*类型,然后就能够拿到客户端对应的套接字,IP地址和端口号,进而调用Service函数为对应客户端提供服务。
class Param { public: Param(int sock, std::string ip, int port) : _sock(sock) , _ip(ip) , _port(port) {} ~Param() {} public: int _sock; std::string _ip; int _port; };
文件描述符关闭的问题
由于此时所有线程看到的都是同一张文件描述符表,因此当某个线程要对这张文件描述符表做某种操作时,不仅要考虑当前线程,还要考虑其他线程。
- 对于主线程accept上来的文件描述符,主线程不能对其进行关闭操作,该文件描述符的关闭操作应该又新线程来执行。因为是新线程为客户端提供服务的,只有当新线程为客户端提供的服务结束后才能将该文件描述符关闭。
- 对于监听套接字,虽然创建出来的新线程不必关心监听套接字,但新线程不能将监听套接字对应的文件描述符关闭,否则主线程就无法从监听套接字当中获取新连接了。
Service函数定义为静态成员函数
由于调用pthread_create函数创建线程时,新线程的执行例程是一个参数为void*,返回值为void*的函数。如果我们要将这个执行例程定义到类内,就需要将其定义为静态成员函数,否则这个执行例程的第一个参数是隐藏的this指针。
在线程的执行例程当中会调用Service函数,由于执行例程是静态成员函数,静态成员函数无法调用非静态成员函数,因此我们需要将Service函数定义为静态成员函数。恰好Service函数内部进行的操作都是与类无关的,因此我们直接在Service函数前面加上一个static即可。
class TcpServer { public: static void* HandlerRequest(void* arg) { pthread_detach(pthread_self()); //分离线程 //int sock = *(int*)arg; Param* p = (Param*)arg; Service(p->_sock, p->_ip, p->_port); //线程为客户端提供服务 delete p; //释放参数占用的堆空间 return nullptr; } void Start() { 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; Param* p = new Param(sock, client_ip, client_port); pthread_t tid; pthread_create(&tid, nullptr, HandlerRequest, p); } } private: int _listen_sock; //监听套接字 int _port; //端口号 };
代码测试
此时我们再重新编译服务端代码,由于代码当中用到了多线程,因此编译时需要携带上-pthread
选项。此外,由于我们现在要监测的是一个个的线程,因此在监控时使用的不再是ps -axj
命令,而是ps -aL
命令。
while :; do ps -aL|head -1&&ps -aL|grep tcp_server;echo "####################";sleep 1;done
运行服务端,通过监控可以看到,此时只有一个服务线程,该服务线程就是主线程,它现在在等待客户端的连接到来。
当一个客户端连接到服务端后,此时主线程就会为该客户端构建一个参数结构体,然后创建一个新线程,将该参数结构体的地址作为参数传递给这个新线程,此时该新线程就能够从这个参数结构体当中提取出对应的参数,然后调用Service函数为该客户端提供服务,因此在监控当中显示了两个线程。
当第二个客户端发来连接请求时,主线程会进行相同的操作,最终再创建出一个新线程为该客户端提供服务,此时服务端当中就有了三个线程。
由于为这两个客户端提供服务的也是两个不同的执行流,因此这两个客户端可以同时享受服务端提供的服务,它们发送给服务端的消息也都能够在服务端进行打印,并且这两个客户端也都能够收到服务端的回显数据。
此时无论有多少个客户端发来连接请求,在服务端都会创建出相应数量的新线程为对应客户端提供服务,而当客户端一个个退出后,为其提供服务的新线程也就会相继退出,最终就只剩下最初的主线程仍在等待新连接的到来。