二、多进程版TCP网络程序
2.1 存在问题
当服务端调用accept函数获取到新连接后不是由当前执行流为该连接对应的客户端提供服务,而是当前执行流调用fork函数创建子进程,子进程为父进程获取到的连接提供服务
由于父子进程是两个不同的执行流,当父进程调用fork创建出子进程后,父进程就可以继续从监听套接字中获取新连接,而不用关心获取上来的连接对应的客户端是否服务完毕
子进程继承父进程的文件描述符表
文件描述符表是隶属于一个进程的,子进程创建后会继承父进程的文件描述符表。如父进程打开了一个文件,该文件对应的文件描述符是3,子进程的3号文件描述符也会指向这个打开的文件,若子进程再创建一个子进程,那么孙子进程的3号文件描述符也同样会指向这个打开的文件
当父进程创建子进程后,父子进程之间保持独立性,此时父进程文件描述符表的变化不会影响子进程。譬如,父子进程在使用匿名管道进行通信时,父进程先调用pipe函数得到两个文件描述符,一个是管道读端的文件描述符,一个是管道写端的文件描述符,此时父进程创建出来的子进程就会继承这两个文件描述符,之后父子进程一个关闭管道的读端,另一个关闭管道的写端,这时父子进程文件描述符表的变化是不会相互影响的,此后父子进程就可以通过这个管道进行单向通信了
对于套接字文件也是一样的,父进程创建的子进程也会继承父进程的套接字文件,此时子进程就能够对特定的套接字文件进行读写操作,进而完成对对应客户端的服务
等待子进程问题
当父进程创建出子进程后,父进程是需要等待子进程退出的,否则子进程会变成僵尸进程,进而造成内存泄漏。因此服务端创建子进程后需要调用wait或waitpid函数对子进程进行等待
阻塞式等待与非阻塞式等待:
- 若服务端采用阻塞的方式等待子进程,那么服务端还是需要等待服务完当前客户端,才能继续获取下一个连接请求,此时服务端仍然是以一种串行的方式为客户端提供服务
- 若服务端采用非阻塞的方式等待子进程,虽然在子进程为客户端提供服务期间服务端可以继续获取新连接,但此时服务端就需要将所有子进程的PID保存下来,并且需要不断花费时间检测子进程是否退出,且编码较为复杂
服务端要等待子进程退出,无论采用阻塞式等待还是非阻塞式等待,都不尽人意。此时可以考虑让服务端不等待子进程退出的方案
2.2 捕捉SIGCHLD信号
当子进程退出时会给父进程发送SIGCHLD信号,若父进程将SIGCHLD信号进行捕捉,并将该信号的处理动作设置为忽略,此时父进程可以继续从监听套接字中获取新连接
该方案实现较为简单,较为推荐
void TcpServer::StartUp() { //设置忽略SIGCHLD信号 signal(SIGCHLD, SIG_IGN); while(true) { //获取连接 struct sockaddr_in foreign; memset(&foreign, '\0', sizeof foreign); socklen_t length = sizeof foreign; int server_socket_fd = accept(_socket_listen_fd, (struct sockaddr*)&foreign, &length); if(server_socket_fd < 0) { cerr << "accept fail" << endl; continue; } string client_ip = inet_ntoa(foreign.sin_addr); uint16_t client_port = ntohs(foreign.sin_port); cout << "New Link: [" << server_socket_fd << "] [" << client_ip << "] [" << client_port << "]" << endl; //处理客户端请求 pid_t id = fork(); if(id == 0) { //child Service(server_socket_fd, client_ip,client_port); exit(4); } } }
网络测试
重新编译程序运行服务端后,可以通过以下监控脚本对服务进程进行监控
while :; do ps axj | head -1 && ps axj | grep tcp_server | grep -v grep;echo "######################";sleep 1;done
一开始没有客户端连接该服务器,此时服务进程只有一个,该服务进程就是不断获取新连接的进程,而获取到新连接后也是由该进程创建子进程为对应客户端提供服务的
此时启动一个客户端,让该客户端连接服务器,此时服务进程就会调用fork函数创建出一个子进程,由该子进程为这个客户端提供服务
若再有一个客户端连接服务器,此时服务进程会再创建出一个子进程,为这个客户端提供服务
这两个客户端分别由两个不同的执行流提供服务,因此这两个客户端可以同时享受到服务,发送给服务端的数据都能够在服务端输出,并且服务端对两个客户端的数据都会进行响应
当客户端陆续退出后,在服务端对应为之提供服务的子进程也会退出,但无论如何服务端都至少会有一个服务进程,这个服务进程的任务就是不断获取新连接
2.3 孙子进程提供服务
- 爷爷进程:在服务端调用accept函数获取客户端连接请求的进程
- 爸爸进程:爷爷进程调用fork函数创建出来的进程
- 孙子进程:爸爸进程调用fork函数创建出来的进程,该进程调用Service函数为客户端提供服务
爸爸进程创建完孙子进程后立刻退出,此时服务进程(爷爷进程)调用wait/waitpid函数等待爸爸进程就能立刻等待成功,此时孙子进程变成孤儿进程被1号进程领养,此后服务进程就能继续调用accept函数获取其他客户端的连接请求。不需要处理孙子进程,其资源由系统释放
关闭对应的文件描述符
服务进程(爷爷进程)调用accept函数获取到新连接后,会让孙子进程为该连接对应的服务端提供服务,此时服务进程已经将文件描述符表继承给了爸爸进程,而爸爸进程又会调用fork函数创建出孙子进程,然后再将文件描述符表继承给孙子进程
而父子进程创建后,其各自的文件描述符表是独立的,不会相互影响。因此服务进程在调用fork函数后,服务进程就不需要再关心刚才从accept函数获取到的文件描述符了,此时服务进程就可以调用close函数将该文件描述符进行关闭
对于爸爸进程和孙子进程来说,是不需要关心从服务进程(爷爷进程)继承下来的监听套接字的,因此服务进程可以将监听套接字关掉
- 对于服务进程来说,调用fork函数后就必须将从accept函数获取的文件描述符关掉。因为服务进程会不断调用accept函数获取新的文件描述符(服务套接字),若服务进程不及时关掉不用的文件描述符,最终服务进程中可用的文件描述符就会越来越少
- 对于孙子进程而言,还是建议关闭从服务进程继承下来的监听套接字。实际就算不关闭监听套接字,最终也只会导致这一个文件描述符泄漏,但还是建议关上。因为孙子进程在提供服务时可能会对监听套接字进行某些误操作,此时就会对监听套接字当中的数据造成影响
- 实际编码时,在爸爸进程fork之前将其监听套接字关闭,孙子进程继承的文件描述符表中自然没有监听套接字了
void TcpServer::StartUp() { while(true) { //获取连接 struct sockaddr_in foreign; memset(&foreign, '\0', sizeof foreign); socklen_t length = sizeof foreign; int server_socket_fd = accept(_socket_listen_fd, (struct sockaddr*)&foreign, &length); if(server_socket_fd < 0) { cerr << "accept fail" << endl; continue; } string client_ip = inet_ntoa(foreign.sin_addr); uint16_t client_port = ntohs(foreign.sin_port); cout << "New Link: [" << server_socket_fd << "] [" << client_ip << "] [" << client_port << "]" << endl; //处理客户端请求 pid_t id = fork(); if(id == 0) { //爸爸进程 close(_socket_listen_fd);//关闭监听套接字 if(fork() > 0) exit(4);//服务进程子进程直接退出 //孙子进程处理 Service(server_socket_fd, client_ip,client_port); exit(5); } close(server_socket_fd);//服务进程关闭连接客户端时获取的文件描述符 waitpid(id, nullptr, WNOHANG);//等待爸爸进程,立即成功 } }
网络测试
while :; do ps axj | head -1 && ps axj | grep tcp_server | grep -v grep;echo "######################";sleep 1;done
此时没有客户端连接服务器,只监控到一个服务进程,该服务进程正在等待客户端的连接请求
此时启动一个客户端,让该客户端连接服务端,此时服务进程会创建出爸爸进程,爸爸进程再创建出孙子进程,之后爸爸进程就会立刻退出,而由孙子进程为客户端提供服务。所以只看到了两个服务进程,其中一个是一开始用于获取连接的服务进程,还有一个就是孙子进程,该进程为当前客户端提供服务,其PPID为1,表明这是一个孤儿进程
启动第二个客户端连接服务器后,就又会创建出一个孤儿进程为该客户端提供服务
两个客户端是由两个不同的孤儿进程提供服务的,因此是能够同时享受服务的,可以看到这两个客户端发送给服务端的数据都能够在服务端输出,并且服务端也会对这两个客户端的数据进行响应
当客户端全部退出后,对应为客户端提供服务的孤儿进程也会跟着退出,这时这些孤儿进程会被系统回收,而最终剩下那个获取连接的服务进程
三、多线程版TCP网络程序
创建进程的成本是很高的,创建进程时需要创建该进程对应的进程控制块(task_struct)、进程地址空间(mm_struct)、页表等结构。而创建线程的成本比创建进程的成本会小得多,因为线程本质是在进程地址空间内运行,创建出来的线程会共享该进程的大部分资源,因此在实现多执行流的服务器时最好采用多线程进行实现
服务进程调用accept函数获取到一个新连接后,可创建一个线程,让该线程为对应客户端提供服务
主线程创建出新线程后,也是需要等待新线程退出回收资源的,否则也会造成资源浪费的问题。但对于线程来说,若不想让主线程等待新线程退出,可以让创建出来的新线程调用pthread_detach函数进行线程分离,当这个线程退出时系统会自动回收该线程所对应的资源。此时主线程就可以继续调用accept函数获取新连接,而让新线程去服务对应的客户端
各个线程共享同一张文件描述符表
文件描述符表维护的是进程与文件之间的对应关系,因此一个进程对应一张文件描述符表。而主线程创建出来的新线程依旧属于这个进程,因此创建线程时并不会为该线程创建独立的文件描述符表,所有的线程看到的都是同一张文件描述符表
当主线程调用accept函数获取到一个文件描述符后,新线程是能够直接访问这个文件描述符的
虽然新线程能够直接访问主线程accept上来的文件描述符,但此时新线程并不知道其所服务的客户端对应的是哪一个文件描述符,因此主线程创建新线程后需要告诉新线程对应应该访问的文件描述符,即告诉每个新线程在服务客户端时,应该对哪一个套接字进行操作
参数结构体
实际新线程在为客户端提供服务时调用Service函数,而调用Service函数时是需要传入三个参数的,分别是客户端对应的套接字、IP地址和端口号。因此主线程创建新线程时需要给新线程传入三个参数,而实际在调用pthread_create函数创建新线程时,只能传入一个类型为void*的参数
这时可以设计一个参数结构体ThreadDate,这三个参数可以存放到ThreadDate结构体中,当主线程创建新线程时就可以定义一个ThreadDate对象,将客户端对应的套接字、IP地址和端口号设置进这个ThreadDate对象中,然后将Param对象的地址作为新线程执行例程的参数进行传入
此时新线程在执行例程当中再将这个void*类型的参数强转为Param*类型,然后就能够拿到客户端对应的套接字,IP地址和端口号,进而调用Service函数为对应客户端提供服务
class ThreadDate { public: ThreadDate(int fd, string ip,uint16_t port):_server_socket_fd(fd),_client_ip(ip),_client_port(port) {} ~ThreadDate() {} public: int _server_socket_fd;//accept获取连接得到文件描述符,用于服务 string _client_ip; uint16_t _client_port; };
文件描述符关闭的问题
所有线程看到的都是同一张文件描述符表,因此当某个线程要对文件描述符表做某种操作时,不仅要考虑当前线程,还要考虑其他线程。
- 对于主线程accept来的文件描述符,主线程不能对其进行关闭操作,该文件描述符的关闭操作应该又新线程来执行。因为是新线程为客户端提供服务的,只有当新线程为客户端提供的服务结束后才能将该文件描述符关闭
- 对于监听套接字,虽然创建出来的新线程不必关心监听套接字,但新线程不能将监听套接字对应的文件描述符关闭,否则主线程就无法从监听套接字当中获取新连接了
Service函数定义为静态成员函数
由于调用pthread_create函数创建线程时,新线程的执行例程是一个参数为void*,返回值为void*的函数。若要将这个执行例程定义到类内,就需要将其定义为静态成员函数,否则这个执行例程的第一个参数是隐藏的this指针
在线程的执行例程中会调用Service函数,由于执行例程是静态成员函数,静态成员函数无法调用非静态成员函数,因此需要将Service函数定义为静态成员函数,恰好Service函数内部进行的操作都不涉及类内数据的修改,因此直接在Service函数前面加上一个static即可
class TcpServer { public: TcpServer(uint16_t port):_socket_listen_fd(-1),_server_port(port) {} void InitServer(); void StartUp(); static void Service(int, string, uint16_t); ~TcpServer(); private: int _socket_listen_fd; uint16_t _server_port; };
void TcpServer::StartUp() { while(true) { //获取连接 struct sockaddr_in foreign; memset(&foreign, '\0', sizeof foreign); socklen_t length = sizeof foreign; int server_socket_fd = accept(_socket_listen_fd, (struct sockaddr*)&foreign, &length); if(server_socket_fd < 0) { cerr << "accept fail" << endl; continue; } string client_ip = inet_ntoa(foreign.sin_addr); uint16_t client_port = ntohs(foreign.sin_port); cout << "New Link: [" << server_socket_fd << "] [" << client_ip << "] [" << client_port << "]" << endl; //处理客户端请求 ThreadDate* ptr = new ThreadDate(server_socket_fd, client_ip, client_port); pthread_t thread_id; pthread_create(&thread_id, nullptr, HandlerClient, (void*)ptr); /*应将ThreadDate数据开辟在堆区,若开辟在主线程栈区,主线程循环accept并处理客户端请求时,会修改TheadDate内数据*/ } } void* TcpServer::HandlerClient(void* args) { pthread_detach(pthread_self());//线程分离,资源由系统回收 ThreadDate* ptr = (ThreadDate*)args; Service(ptr->_server_socket_fd, ptr->_client_ip, ptr->_client_port); delete ptr; return nullptr; }
网络测试
监控时使用的不再是 ps -axj 命令,而是 ps -aL 命令
while :; do ps -aL|head -1&&ps -aL|grep tcp_server;echo "####################";sleep 1;done
启动服务端,通过监控发现此时只有一个服务线程(主线程),现在在等待客户端的连接请求
当一个客户端连接到服务端后,此时主线程就会为该客户端构建一个参数结构体,然后创建一个新线程,将该参数结构体的地址作为参数传递给这个新线程,此时该新线程就能够从这个参数结构体中提取出对应的参数,然后调用Service函数为该客户端提供服务,因此在监控中显示了两个线程
当第二个客户端发来连接请求时,主线程会进行相同的操作,最终再创建出一个新线程为该客户端提供服务,此时就有了三个线程
由于为这两个客户端提供服务的是两个不同的执行流,因此这两个客户端可同时享受服务端提供的服务,发送给服务端的消息都能够在服务端打印,并且这两个客户端都能够收到服务端的回显数据
此时无论有多少个客户端发来连接请求,在服务端都会创建出相应数量的新线程为对应客户端提供服务,而当客户端一个个退出后,为其提供服务的新线程也就会相继退出,最终就只剩下主线程在等待新连接的到来