一个客户一个子进程,是阻塞式网络编程,适合于长连接,不适合短连接、并发数不大的情况,尤其不适合fork()的开销大于本身服务的情况.
编程模型
(1)迭代服务器,TCP迭代服务器总是完全处理完某个客户的请求之后才会转向为下一个客户服务,因此对下一个用户的响应时间不够好,比较适合短连接服务(一般会有一方主动断开TCP连接);如果在单核处理器上,由于其没有进程控制的时间,可以使用该程序作为基准测试程序,来比较其他模型的服务器程序的运行时间,进而可以得到进程的控制时间;
(2)并发服务器1,它通常在accept阻塞,阻塞返回后调用fork派生一个子进程来处理每个客户,每一个客户一个进程,因此客户数目的限制在于子进程数目的限制;即使在写时复制的情况下,fork的开销的还是很大的,当有数万的连接时,弊端就比较明显了;
(3)并发服务器2,它与并发服务器1类似,只不过预先派生一定数量为N的子进程,同时也监听,这样当各个客户连接到达时,这些子进程就能够立即为它们服务,无需fork开销;但是如果连接数等于N时(注,父进程不参与服务),此时子进程将会完全被使用完,新到的连接需等到下一个子进程可用,但是如果连接队列的数目还未到达listen调用的backlog数,虽然此时客户连接已经被内核完成三次握手,但是无法被服务,需要等到子进程执行到accept才可返回,那么客户端将会明显察觉到服务器在响应时间上的恶化,虽然connect可能会立即返回,第一个请求在一段时间之后才会被服务器处理;当然服务端还有可能会有惊群问题,如果多个子进程都阻塞在accept,当一个客户连接到达时,所有的这些子进程都会被唤醒,这是因为所有的子进程的监听描述符都指向同一个socket结构,此时这些子进程将会争用这个连接,最终所有的子进程被唤醒,内核进程调度到的第一个子进程将会获得这个客户连接,而其他几个子进程将会继续睡眠(有些内核这些子进程可能根本不会唤醒,或直接accept返回一个错误);据说Linux系统已经在内核态已解决惊群问题;
(4)并发服务器3,它与并发服务器2类似,只不过在accpet加上一段锁,使得accept这段代码成为临界区;由原先的accept争用变成了锁争用,最终只有一个进程阻塞在accept上;这样其他进程进入临界区后,将会独占accept的使用;
(5)并发服务器4,它使用主进程分发机制;子进程不进行accept调用,统一由父进程accept然后将连接描述符传递给对应的子进程,然后由由其服务;父进程可使用普通的轮转法来选择子进程来进行连接描述符的传递;
特点
(1)TCP是一个全双工的协议,同时支持read()和write();而阻塞式网络编程中,服务器主进程通常阻塞在accept上,而由子进程具体负责与客户端通信,客户端进程通常阻塞在read系统调用上,等待客户端发来的命令;这样就需要服务端和客户端的编程需要相互配合起来;假设客户端进程由于错误的程序逻辑,双方有可能出现通信死锁情况;此外,某些客户端继续阻塞读连接数据,又需要读键盘输入,如果阻塞的读连接,那么是无法从键盘读输入的;
(2)服务器为每一个连接准备一个进程,一个这样的连接将会独占这样的进程,服务器的开销较大;
(3)一个客户一个进程模型,关键的特点在于fork()后对监听描述符的影响;并发服务器1仅有父进程监听后产生连接描述符,然后由fork后的子进程来处理;而并发服务器2,3中的所有进程都进行监听(父进程可以不监听),因此涉及到对accept的争用;并发服务器4仍是只有父进程进行accept,然后通过文件描述符的传递,使得子进程获得连接描述符;
实现内容
(1)下面是针对并发服务器1的具体实现;
(2)实现的内容是一个echo服务器,由客户端从键盘输入相关内容,发送给服务器,然后由服务器收到后转发至客户端,客户端打印至终端;
(3)服务器不主动断开连接,而由客户端从键盘获得EOF或客户端退出后,服务器也将会退出;
下面为TcpServer实现
TcpServer头文件
<span style="font-size:14px;">class TcpServer final
{
public:
TcpServer(const TcpServer&) = delete;
TcpServer& operator=(const TcpServer&) = delete;
explicit TcpServer(const struct sockaddr_in& serverAddr);
~TcpServer();
void start();
static void signalHandle(int sig);
private:
void _service(int conn);
const int _listenfd;
const struct sockaddr_in _serverAddress;
bool _started;
}; </span>
说明几点:
(1)TcpServer不具有值语义,具有对象语义,拷贝一个TcpSever并不能让系统多一个一模一样的TcpServer主进程服务器(包括所管理的孩子进程),假设拷贝了一份TcpServer,那么系统中仅仅是多了对TcpServer本身数据的拷贝,而主进程和孩子进程仍然是不变的,因此对象拷贝是禁止的;继承也是被禁止的,因为TcpServer不具有多态语意;分别使用C++11新特性中delete和final来禁止拷贝和继承;
(2)_listenfd作为监听描述符;_serverAddress作为绑定的服务器端地址,需要在TcpServer显式指定;
(3) static void signalHandle(int sig);作为SIGCHID信号的处理函数,对结束的子进程进行处理,立即释放最后的资源,否则若没有该信号的处理函数,孩子进程退出时,若父进程不及时回收,孩子进程仍将占用少部分资源;
构造函数
<span style="font-size:14px;">TcpServer::TcpServer(const struct sockaddr_in& serverAddr):
_listenfd(sockets::createBlockingSocket()),
_serverAddress(serverAddr),
_started(false)
{
assert(_listenfd >= 0);
sockets::setReuseAddr(_listenfd);
sockets::signal(SIGCHLD, &TcpServer::signalHandle);
} </span>
说明几点:
(1)sockets中的相关函数的定义与实现,请见完整代码;
(2)初始化_listenfd监听描述符,绑定_serverAddress服务器地址后;将服务器地址设置为可重用,这样bind一个正处于TIME_WAIT状态上的连接会失败,因此使用setReuseAddr()设置socket的SO_REUSEADDR选项;
(3) sockets::signal(SIGCHLD, &TcpServer::signalHandle);设置SIGCHLD信号处理程序;其中TcpServer::signalHandle为静态成员函数,这样才能保证与普通函数具有相同的语义;
服务器启动-
void TcpServer::start() { assert(!_started); sockets::bind(_listenfd, _serverAddress); sockets::listen(_listenfd); printf("TcpServer start...\n"); _started = true; while (_started) { int connfd = sockets::accept(_listenfd, NULL); //so far, we will not concern client address if (connfd >= 0) { _service(connfd); } else { printf("In TcpServer::_service, accept error : %s\n", strerror_r(errno, g_errorbuf, sizeof g_errorbuf)); } ::close(connfd); } }
(1)首先调用bind进行地址绑定,以及listen进行监听,然后进入主服务程序;
(2)如果没有客户端连接,那accept将会阻塞,这样就会带来弊端,主进程不能服务于其他程序;当有客户端连接到来,accept将会返回一个连接描述符;
(3)在_service将进行fork调用以及相关服务,最后调用exit()进行退出;请注意最后的::close(connfd),并不会关闭connfd描述符,仅仅是将connfd上引用计数递减为1,因为fork()后,子进程必然为递增connfd的引用计数;如果子进程close或exit会将connfd的引用计数减为0,那么服务器内核将会发送FIN报文给客户端,这是从ESTABLISHED状态变为FIN_WAIT_1状态,对应服务器主动断开连接的情况;但是如果客户端已经发送了Fin报文,那么connfd减为0,那么服务器端将发送FIN报文,从CLOSE_WAIT变为LAST_ACK状态,对应服务器端被动断开连接的情况;本服务器实现是服务器被动接受连接,这样可以将TIME_WAIT的时间留给客户端,但是会带来客户端故意不断开连击的情况;
服务实现-
void TcpServer::_service(int connfd) { if (::fork() == 0) { sockets::close(_listenfd); //important char buf[20]; int n; while ((n = sockets::read(connfd, buf, sizeof buf)) > 0) { sockets::writen(connfd, buf, n); } ::exit(0); } }
说明几点:
(1)服务内容,主要就是将从客户端读取的内容直接转发至对应的连接,由于read和write属于阻塞操作,可以保证接收到的字节全部转发至客户端;
(3)最后当read到0,说明客户端已经断开连接;服务器执行::exit(0)将会使内核发送Fin报文,服务器端将会从CLOSE_WAIT变为LAST_ACK状态;
处理僵尸进程-
void TcpServer::signalHandle(int signo) { int stat; pid_t pid; while ((pid = waitpid(-1, &stat, WNOHANG)) > 0) { printf("child %d terminated\n", pid); } }
(1)僵尸进程为:每一个子进程终止后,并不完全释放它的所有资源,保留一些如进程ID,终止状态,资源利用信息(CPU时间等),因为这些终止后的信息父进程可能要使用记录它们的终止信息;如果父进程终止后,未收回这些子进程,继承这些子进程的init进程将会清理它们,而本身该父进程由其父亲负责清理;
(2)僵尸进程毕竟占用资源,耗费内存和资源,每一个子进程终止时都会发送SIGCHLD信号给父进程,因此父进程可建立SIGCHLD可使用wait系列函数来处理已经终止的孩子进程;
(3)使用waitpid(),并使用WNOHANG来进行非阻塞的进行处理终止进程,主要是因为当主进程当多个进程在信号处理程序前提交SIGCHLD信号时,信号处理程序只会执行一次,使用while循环,可以循环遍历处理每一个已经终止的进程;而在信号处理期间的终止进程提交的SIGCHLD将会被阻塞;等到返回到主程序后,发现又有SIGCHLD信号,进而又会调用信号处理程序来处理终止的子进程;
析构函数-
TcpServer::~TcpServer() { if (_started) signalHandle(SIGCHLD); _started = false; }
-
class TcpClient final { public: TcpClient(const TcpClient&) = delete; TcpClient& operator=(const TcpClient&) = delete; TcpClient(const struct sockaddr_in* clientAddr = NULL); void request(const struct sockaddr_in& serverAddr); private: const int _connectfd; const struct sockaddr_in* _clientAddress; };
说明几点:
(1)TcpClient分别使用C++11新特性中delete和final来禁止拷贝和继承;
(2)_connectfd作为连接描述符;_clientAddress作为绑定的客户端地址,可以为NULL,这样内核将会随机选取绑定到客户端的地址;
请求实现-
void TcpClient::request(const struct sockaddr_in& serverAddr) { int err = sockets::connect(_connectfd, serverAddr); if (err < 0) { printf("In TcpClient::request, connect error : %s\n", strerror_r(errno, g_errorbuf, sizeof g_errorbuf)); return; } char buf[10]; while (fgets(buf, sizeof buf, stdin)) { int n = sockets::writen(_connectfd, buf, strlen(buf) + 1); printf("client send (%d bytes): %s\n", n , buf); if ((n = sockets::readn(_connectfd, buf, n)) == 0) //receive Fin { printf("server exit\n"); break; } printf("client receive : %s\n", buf); } }
(1)客户端首先从键盘读入一行数据,注意若数据不足8个字节,可以完全发送成功,因为‘\n’,'\0'在fgets均需要字节数;而如果大于8个字节,根据现有的程序逻辑,fgets将会执行循环,第二次执行fgets将第一次未存入的字符继续加入到缓冲中继续发送;
(2)由于writen将保证缓冲区发送,readn将从连接再次读入刚才发送的缓冲区,如果此时读取0,说明服务器端发送了Fin报文,那就说明服务器意外终止了,那么客户端也将会退出;