上个博客的最后,说要写一个功能齐全一些服务器,所以,这边博客主要对这个服务器进行一个简单的介绍。这个服务器,是一个聊天室服务器。 当客户端连接到服务器后,就可以收到所有其他客户端发送给服务器的内容。主要实现原理如下:
1.IO复用:
利用epoll函数,对多个套接字进行监听,包括:listenfd,标准输入套接字,与子进程通讯的套接字还有信号处理的套接字。
listenfd:这个套接字主要是服务器端用来监听是否有新的客户端的连接的。一旦有连接,则视为新的客户到来,然后,准备连接,分配用户内存,对应各种信息,连接成功后,fork一个子进程进行对这个连接进行一对一的处理。这里的处理,主要是对各种套接字进行监听,并进行相应的处理,下文的多进程部分会有。
标准输入套接字:为的是在服务器端也能输入一些信息,并让这个服务器根据输入的信息进行反应。不过,我这里的反应主要是让服务器原样输出。
与子进程通讯的套接字:因为没个客户,都是用一个子进程在单独的处理,各个子进程之间的通信,首先需要通过父进程,然后再进行广播,从而实现子进程与其他所有子进程之间的通信。这里,子进程与父进程之间的通信,是靠管道完成的。但是,传统的管道是pipe()函数创建的,只能单工的通信,我这里为了双工的通信,使用的是socketpair()创建的管道,管道的两端都可以进行读写操作。如果子进程有数据写给父进程,一般是它有小弟到达,于是,父进程告诉所有其他子进程,说数据可读了,于是各子进程往对应的客户端写数据。
信号处理文件描述符:为了将事件源统一,于是将信号处理的管道的描述符也用epoll来统一监听。这里,信号处理函数要做的事情是如果有信号出现,则向管道里写消息。于是epoll接收到这个消息后,再调用更具体的信号处理函数,进行具体的处理。
上面说的都是父进程要做的内容,下面说说子进程需要完成的内容:
子进程:每个客户端需要一个子进程对其进行处理。父子进程间的通讯方法、各种联系已经在fork()函数调用之间记录好了。子进程需要建立自己的epoll注册事件表。对自己的一些文件描述符使用epoll函数进行监听。这里的epoll主要监听一下:与客户端的连接套接字,与父进程的通信管道套接字和信号处理套接字。
与客户端的连接套接字:这个是用来读写的主要依据。是服务器与客户端通信的窗口。只需对这个套接字进行读写即可。
与父进程的通信套接字:上文提到了该通信管道。用于父子进程间交换信息。一般是客户有数据到达了,子进程要通知父进程,父进程知道这个消息到达后,告诉其他子进程,有人发言了,你们把这个发言发送到各自的客户端去。子进程得知父进程的通知后,对各自的连接套接字进行写操作。
子进程的主要处理函数是runchild函数,该函数如下:
int runchild(user* curuser,char *shmem) { assert(curuser!=NULL); int child_epollfd=epoll_create(5); assert(child_epollfd!=-1); epoll_event events[MAX_EVENT_NUMBER]; int conn=curuser->conn; addfd(child_epollfd,conn); addfd(child_epollfd,curuser->pipefd[1]); int stop_child=0; int ret=0; while(!stop_child) { printf("in child\n"); int number=epoll_wait(child_epollfd,events,MAX_EVENT_NUMBER,-1); if(number<0 && errno!=EINTR) { printf("epoll error in child"); break; } for(int i=0;i<number;i++) { int sockfd=events[i].data.fd; if(sockfd ==conn && (events[i].events & EPOLLIN) ) { memset(shmem+curuser->user_number*BUF_SIZE,'\0',BUF_SIZE); ret=recv(conn,shmem+curuser->user_number*BUF_SIZE,BUF_SIZE-1,0); if(ret<0 && errno!=EAGAIN) stop_child=1; else if (ret==0) stop_child=1; else //通知父进程,有了数据啦,让父进程告诉别的子进程去读吧。哈哈 { shmem[curuser->user_number*BUF_SIZE+ret]='\0'; printf("some thing\n"); send(curuser->pipefd[1],(char*)&(curuser->user_number),sizeof(curuser->user_number),0); } } else if (sockfd==curuser->pipefd[1] && events[i].events & EPOLLIN) { printf("some thing from father\n"); int client_number; ret=recv(sockfd,(char*)&client_number,sizeof(client_number),0); if(ret<0 && errno!=EAGAIN) { stop_child=1; } else if (ret==0) stop_child=1; else //从收到的客户端那里读取内容,往自己这里写 { // printf("rec from father,then write to his client\n"); char tmpbuf[BUF_SIZE*2]; sprintf(tmpbuf,"client %d says: ",client_number); //memcpy(tmpbuf+sizeof(tmpbuf),shmem+(client_number)*BUF_SIZE,BUF_SIZE); sprintf(tmpbuf+15,"%s",shmem+(client_number*BUF_SIZE)); // send(conn,tmpbuf,sizeof(tmpbuf),0); send(conn,tmpbuf,strlen(tmpbuf),0); } } } } }
进程间通信:
上文说到了进程间通信的方法,一个是管道,主要是信号处理部分。一个是套接字,父子进程间的管道实际是本地域的套接字。还有一个是共享内存。父进程开辟一块共享内存,用于各种进程间的内容共享。于是,每个子进程可以通过这块内从,与其他子进程进行信息的共享。这就是聊天室的内容能够共享的一个原因。而且,还不需要大量的数据拷贝,具有一定的效率。共享内存代码如下
//开辟一块内存,返回一个共享内存对象 shmfd=shm_open(shm_name,O_CREAT|O_RDWR,0666); assert(shmfd!=-1); //将这个共享内存对象的大小设定为 ** int ret=ftruncate(shmfd,USER_LIMIT*BUF_SIZE); assert(ret!=-1); //将刚才开辟的共享内存,关联到调用进程 share_mem=(char *) mmap(NULL,USER_LIMIT*BUF_SIZE,PROT_READ|PROT_WRITE,MAP_SHARED,shmfd,0); assert(share_mem!=MAP_FAILED);
管道如下:
typedef struct user { int conn; int stop; int pipefd[2]; //父子进程间的管道。 int user_number; sockaddr_in client_addr; char bufread[BUF_SIZE]; char bufwrite[BUF_SIZE]; pid_t pid; // which process deal with this user } user;//父子进程间的管道int piperet = socketpair ( AF_UNIX , SOCK_STREAM , 0 , users [ user_number ]. pipefd );assert ( piperet == 0 );
//用来作为信号与epoll链接的管道 int retsigpipe=socketpair(AF_UNIX,SOCK_STREAM,0,sig_pipefd);
信号处理:
这里主要处理3个信号,sigterm,sigint,sigchld。对于前两个信号,父子进程的处理方法有一些不同。添加要处理的信号及其处理函数如下:
int add_sig(int signum, void(handler)(int) ) { struct sigaction sa; memset(&sa,'\0',sizeof(sa)); sa.sa_handler=handler; sa.sa_flags|=SA_RESTART; //这个的意思是将所有的信号都监听,然后都是调用一个handler来处理。这样看上去好像不太合理。但是,后面我们就知道,为了统一事件源,在此将所有信号一视同仁的处理,在接下来的IO复用中,会有好处的。 sigfillset(&sa.sa_mask); assert(sigaction(signum,&sa,NULL)!=-1); }统一的信号处理函数如下:(关联到epoll)
/* 当信号发生时,调用这个函数来处理该信号*/ void sig_handler(int sig) { int save_errno=errno; int msg=sig; send(sig_pipefd[1],(char*)&msg,sizeof(msg),0); errno=save_errno; }
epoll中,出现信号事件了,调用具体的函数处理函数:
/*具体用来处理信号事件的函数*/ void sig_concrete_handler(int sig,int epollfd) { printf("signal chld occur\n"); pid_t pid; int stat_loc; while(pid=waitpid(-1,&stat_loc,WNOHANG)>0) //pid=waitpid(-1,&stat_loc,WNOHANG)>0; if(pid>0) { /*作一些具体的回收工作,比如关闭子进程中打开的socket,但是,我们目前无法直接获得该socket,无法关闭;只能获取目前的子进程的pid,所以,需要建立pid与连接socket之间的联系,可以用一个简单的数组作对应。也可以用一个结构体(记录多种数据)+一个全局化的数组来作对应,这里用subprocess将pid于user对应*/ printf("close process %d\n",pid); subprocess[pid].u->stop=1; subprocess[pid].u->pid=-1; //不再监听与父进程通信的管道了 epoll_ctl(epollfd,EPOLL_CTL_DEL,subprocess[pid].u->pipefd[0],0); //关闭与父进程通信的管道了 close(subprocess[pid].u->pipefd[0]); //关闭与客户端的连接 close(subprocess[pid].u->conn); } }
完成的代码见:http://git.oschina.net/mengqingxi89/codelittle/blob/master/codes/echoserver/final_echo_server.cpp
编译于运行:
因为使用了共享内存,需要在编译的时候加上-lrt选项。即 g++ -lrt -o outfile file.cpp.然后运行该文件。
./outfile 127.0.0.1 12345
再开多个telnet 127.0.0.1 12345 就可以进行多个telnet之间的聊天了。
我接下来写一个简单的客户端,不用telnet了,再把多进程改成进程池。然后把多进程改成多线程,再改成线程池,敬请期待。