代理服务器源代码分析--谈谈如何学习linux网络编程
Linux是一个可靠性非常高的操作系统.Linux发行版一直被用来作为服务器的操作系统,并且已经在该领域中占据重要地位。根据2006年9月en:Netcraft的报告显示,十个最大型的网络托管公司有八个公司在其Web服务器运行Linux发行版。Linux强大的功能也是一个非常重要的原因,尤其是Linux强大的网络功能更是引人注目,网络功能在Linux下占有核心的地位。放眼今天的银行网络业务和曾经红透半边天的电子商务,到今天的云计算Hadoop、海量数据处理等 无不都基于Linux的解决方案。因此Linux网络编程是非常重要的,而且Linux网络编程本身就是一件有趣的事情。在刚开始学习linux网络编程时往往摸不着头脑,刚开始时我们往往不知到如何编写linux上的网络程序,这是我们要做的不是刚开始就在电脑面前 纠结怎么去写自己的所谓高效的程序,我们应该在读了一些代码的基础上 来形成我们自己的编程思路(为何要读代码)我们在看这些代码的基础之上然后写出自己的代码,可能会更好些,并且你将会体会其中的乐趣。下面我就从一段proxy_epoll源代码开始,谈谈Linux网络编程的一些基础知识,带领大家进入linux网络编程的世界。
proxy_epoll 程序是在CarlHarris的程序proxy(下载地址)的基础之上形成的,这位大虾编写了这段代码并将其散播到网上供大家学习讨论,然后我在学习这段代码之后,又在读了写关于epoll的书籍之后突发奇想的把它改造成具有epoll模型的简单代理服务器。代码虽然简单的描述了proxy操作,但它不仅清晰地描述了客户机/服务器系统的概念和描述了Linux网络编程的其他很多有用的知识,对于Linux网络编程的初学者是个不错的参考资料。
关于程序proxy_epoll的用法,我们可以使用这个代理登录其它主机的服务端口。程序proxy_epoll的用法:
./proxy_epoll <proxy_port> <remote_host><service_port>
其中参数proxy_port是指由我们指定的代理服务器端口。参数remote_host是指我们希望连接的远程主机的主机名,IP地址也同样有效。这个主机名在网络上应该是唯一的,如果您不确定的话,可以在远程主机上使用uname-n命令查看一下。参数service_port是远程主机可提供的服务名,也可直接键入服务对应的端口号。这个命令的相应操作是将代理服务器的proxy_port端口绑定到remote_host的service_port端口。然后我们就可以通过代理服务器的proxy_port端口访问remote_host了。
例如一台计算机,网络主机名是dane@ubuntu,IP地址为172.16.0.12(内网IP)这个机器是我们指定的remote_host远程主机。
网络配置如下:
wang@wang-OptiPlex-320 网络配置如下:
如果在我的计算机wang@wang-OptiPlex-320(ip为172.16.0.11)上执行命令:
wang@wang-OptiPlex-320:~/desk$sudo ./proxy_epoll 5000 172.16.0.12 telnet
此时我们在机器wang@wang-OptiPlex-320 回车后效果如下(此时代理服务器变为守护进程):
wang@wang-OptiPlex-320:~/desk$
此时可知机器 wang@wang-OptiPlex-320 的端口5000已经成为代理服务器的监听端口。
查看 5000端口被打开命令:
netstat -anpl |grep 5000
(a代表全部(all),这个所谓的全部就是指包括正在监听的端口,p:显示占用该端口号的进程,n:直接显示端口号,l:显示正在被监听的端口)
wang@wang-OptiPlex-320:~$sudo netstat -anpl | grep 5000
结果: tcp 0 0 0.0.0.0:5000 0.0.0.0:* LISTEN 3304/proxy
可知代理服务启动正常,且进程的ID 为3304 (最后不需要代理服务时可以通过sudo kill 3304,关闭它)。
此外也可以通过命令: sudo netstat -tnpl 来查看所有正在监听的被打开的进程ID及端口号。
那么我们就可以通过下面这条命令访问172.16.0.12的telnet端口(注意此时,wang-OptiPlex-320 telnet的服务已被绑定到代理服务器的5000端口)。
此时我们在另一台机器上(操作系统为:win7,IP为 172.16.0.139)的cmd上键入命令:telnet 172.16.0.11 5000
(即客户主机win7,172.16.0.139,通过代理服务器(wang@wang-OptiPlex-320,172.16.0.11 端口5000)访问目的主(dane@ubuntu,172.16.0.12 telnet 23端口))
-----------------------------------------------------------------
C:\Users\Administrator>telnet 172.16.0.11 5000
回车后效果如下:
-----------------------------------------------------------------
上面的绑定操作也可以使用下面的命令:
wang@wang-OptiPlex-320:~/desk$./proxy_epoll 5000 172.16.0.11 23
23是telnet服务的标准端口号,其它服务的对应端口号我们可以在/etc/services中查看。
实验平台:
实验中主要利用两台机器还有一个虚拟机。关于如何让电脑中的虚拟机就像真实网络中的局域网中的硬件电脑。我们需要利用桥接技术,具体参见资源VMware虚拟机配置Ubuntu上网(Bridged)的配置方式。
利用这个程序我们可以很容易的实现从外网访问我们内网的计算机,方法如下:
首先在我们内网的路由器上把某个端口绑定到我的计算机上(假设你的计算机配置的是静态IP比如192.168.16.22,你可以把路由器上端口22222绑定到你 的IP上)这样这要在你的linux系统上运行程序 ./proxy_epoll 2222 192.168.16.22 ssh 。 然后你在外网的计算机通过ssh就可以通过内网的路由器外 网 IP + 我们绑定的端口 2222 访问我们的内网的计算机了(想想其中的原理,是不是很有趣)。
proxy_epoll.h
#ifndef PROXY_EPOLL #define PROXY_EPOLL /* * 功能:将普通进程改造成守护进程 * 参数:listenfd为服务器监听套接子 */ void daemonize(int listenfd); /* * 功能:设置描述符为非阻塞 * 参数:fd即为所要设置描述符 */ int set_socket_noblock(int fd); /* * 功能:连接远程主机 * 参数:usersock是需要代理的用户socket,argv是main传进的参数 */ int connect_isolate(int usersockfd, char *argv[]); /* * 功能: 代理服务器主题部分,创建代理服务器,并且利用epoll模型进行相应的事件处理。 * 参数: proxy_port:代理服务端口 argv:main函数传进来关于 远程主机地址及其服务端口信息 */ void proxy(int proxy_port, char *argv[]); /* * 功能:重定向标准输入输出 * 原因:因为守护进程脱离控制终端,不能简单把出错信息写的标准输入输出, * 此外可以调用syslogd守护进程,用户调用syslog().但有时syslog()系统调用 * 会不起作用,此时无法用syslog()记录出错信息。那么此时只能重定向标准输 * 入输出了。 * 参数:infile为重定向的标准输入文件名,outfile为重定向的标准输出文件名 * errfile为重定向的标准错误输出文件名。 */ void redirect_stdIO(char *inFile, char *outFile, char *errFile); /* * 功能:输出错误信息,在文件/tmp/daemon.log * 参数:msg为输出的错误信息 */ void errout(char *msg); #endif
proxy_epoll.c
#include <stdio.h> #include <errno.h> #include <string.h> #include <ctype.h> #include <stdlib.h> #include <syslog.h> #include <sys/types.h>/* syslog定义*/ #include <sys/stat.h> #include <sys/socket.h> #include <sys/epoll.h> #include <errno.h>/* 错误号定义*/ #include <fcntl.h>/* 文件控制定义*/ #include <unistd.h> /* Unix 标准函数定义*/ #include <sys/ioctl.h> #include <netdb.h> #include <arpa/inet.h> #include <map> /* C++ STL map容器*/ #include "proxy_epoll.h" using namespace std; #define TCP_PROTO "tcp" #define LOG_FILE "/tmp/daemon.log" #define MAXEVENTS 64 // 开启daemon, stdout, stderr将被输出到/tmp/daemon.log int main(int argc, char *argv[]) { int i, len, proxy_port; if(argc < 4){ printf("Usage:%s<proxy-port> <host | ip> <service-name | port-number>\n", argv[0]); exit(1); } char buf[32]; strcpy(buf, argv[1]); len = strlen(buf); for(i=0 ; i < len; i++) if(!isdigit(buf[i])) break; if(len != i){ printf("Invalid proxy port %s\n", proxy_port); exit(1); } proxy_port = atoi(argv[1]); proxy(proxy_port,argv); return 0; } 详细源码见附件 ......
下面详细的介绍下这段代码:
下面我们来介绍一些程序中实现的函数。
@ main 函数
// 开启daemon, stdout, stderr将被输出到/tmp/daemon.log int main(int argc, char *argv[]) { int i, len, proxy_port; if(argc < 4){ printf("Usage:%s<proxy-port> <host | ip> <service-name | port-number>\n", argv[0]); exit(1); } char buf[32]; strcpy(buf, argv[1]); len = strlen(buf); for(i=0 ; i < len; i++) if(!isdigit(buf[i])) break; if(len != i){ printf("Invalid proxy port %s\n", proxy_port); exit(1); } proxy_port = atoi(argv[1]); proxy(proxy_port,argv); return 0; }
mian()函数顾名思义是我们的主函数。他的的作用就是检查用户输入的命令行参数是否正确,若果正确,然后调用proxy()进行代理服务。
检验命令行参数:
函数首先要检测命令行参数是否符合程序的要求,即在命令后紧跟代理服务器端口、远程主机名和服务端口号,如果不满足上述要求,则代理服务器程序结束。
首先传送代理服务器端口proxy_port,在这里程序调用了一个系统函数isdigit()检验用户输入的端口号是否有效。isdigit()的具 体描述为:
在将有效端口号传递给函数proxy之后,还要将其转换成为网络字节顺序。这是因为网络中存在着多个公司的不同设备,这些设备表示数据的字节顺序是不同的。例如在内存地址0x1000处存储一个16位的整数FF11,不同公司的机器在内存中的存储方式也不相同,有的将FF置于内存指针的起始位置0x1000,11置于0x1001,这称为big-endian顺序;有的却恰恰相反,即little-endian顺序。这种基于主机的数据存储顺序就称为主机字节顺序(host byte order)。为了在不同类型的主机之间进行通信,TCP/IP网络协议就规定了一种统一的网络字节顺序,这种顺序被规定为big-endian顺序。所以数据的网络字节顺序和主机字节顺序有可能是不同的,因此在编写通信程序时一定要注意不同顺序之间的转换。所以,程序中一定要有例程中这样的语句:
proxy_port =atoi(argv[1]);
在proxy函数中 需要调用htons 进行字节序转换。
函数htons()的作用就是将主机字节顺序转换为网络字节顺序。它的具体描述为:
-----------------------------------------------------------------它的作用是将字符指针nptr指向的字符串转换成相应的整数并将其作为结果返回。这个操作与函数调用strtol(nptr,(char **)NULL,10)的效果几乎完全相同,唯一的区别是atoi()没有出错返回信息。之所以要调用这个函数是因为,系统在读取命令行的时候将所有的参数都作为字符串处理,所以我们必须将其转换为整数形式。
@ errout 函数。
// 写错误日志 void errout(char *msg) { if(msg)// 开启daemon错误将被输出到/tmp/daemon.log printf("%s\n", msg); exit(1); }
@redirect_stdIO 函数。
// 重定向标准输入输出 void redirect_stdIO(char *szInFile, char *szOutFile, char *szErrFile) { int fd; openlog("proxy_epoll_log", LOG_CONS | LOG_PID, 0); if (NULL!= szInFile) { fd = open(szInFile, O_RDONLY| O_CREAT, 0666); if (fd> 0) { // 标准输入重定向 if (dup2(fd, STDIN_FILENO)< 0) { syslog(LOG_ERR, "redirect_stdIO dup2 in"); exit(1); } close(fd); } else syslog(LOG_ERR, "redirect_stdIO open %s: %s\n", szInFile, strerror(errno)); } if (NULL != szOutFile) { fd = open(szOutFile, O_WRONLY| O_CREAT | O_APPEND/*| O_TRUNC*/, 0666); if (fd> 0) { // 标准输出重定向 if (dup2(fd, STDOUT_FILENO)< 0) { syslog(LOG_ERR, "redirect_stdIO dup2 out"); exit(1); } close(fd); } else syslog(LOG_ERR, "redirect_stdIO open %s: %s\n", szOutFile, strerror(errno)); } if (NULL!= szErrFile) { fd = open(szErrFile, O_WRONLY| O_CREAT | O_APPEND/*| O_TRUNC*/, 0666); if (fd> 0) { // 标准错误重定向 if (dup2(fd, STDERR_FILENO)< 0){ syslog(LOG_ERR, "RedirectIO dup2 error\n"); exit(1); } close(fd); } else syslog(LOG_ERR, "redirect_stdIO open %s: %s\n", szErrFile, strerror(errno)); } closelog(); }
@proxy 函数。
/ /* 创建代理服务器端,等待客户端的连接 并对接受到的数据进行相应的处理 */ void proxy(int proxy_port, char *argv[]) { int i,ret, nready, len; map<int, int> sockfd_map; int efd, listenfd, usersockfd, isosockfd; //声明epoll_event结构体的变量,ev用于注册事件,数组用于回传要处理的事件 struct epoll_event ev, *pevents; struct sockaddr_in serv, cli; bzero(&serv, sizeof(serv)); serv.sin_family = AF_INET; serv.sin_port = htons(proxy_port); serv.sin_addr.s_addr = htonl(INADDR_ANY); // daemonize daemonize(listenfd); listenfd = socket(AF_INET, SOCK_STREAM, 0); if(listenfd < 0) errout("socket error"); // set listen nonblock ret = set_socket_noblock(listenfd); if(ret < 0) errout("set socket noblock"); ret = bind(listenfd, (struct sockaddr*)&serv, sizeof(serv)); if(ret < 0) errout("bind error"); // ready to listen ret = listen(listenfd,10); if(ret <0 ) errout("listen error"); efd = epoll_create1(0); if( efd == -1) errout("epoll_create1 error"); //设置与要处理的事件相关的文件描述符 ev.data.fd = listenfd; //设置要处理的事件类型 ev.events = EPOLLIN | EPOLLET; //注册epoll事件 if(epoll_ctl(efd, EPOLL_CTL_ADD, listenfd, &ev) < 0) errout("epoll_ctl error"); //生成用于处理accept的epoll专用的文件描述符 pevents =(struct epoll_event*)calloc(MAXEVENTS, sizeof(struct epoll_event)); // event loop for(; ;){ //等待epoll事件的发生 nready = epoll_wait(efd, pevents, MAXEVENTS, -1); //处理所发生的所有事件 for(i = 0; i < nready; i++){ // an error on this fd or not read read if((pevents[i].events & EPOLLERR) || (pevents[i].events & EPOLLHUP) || (!(pevents[i].events & EPOLLIN))){ perror("epoll_wait"); close(pevents[i].data.fd); continue; }// 监听到有新的连接 else if(listenfd == pevents[i].data.fd){ // one or more connections for(; ;){ socklen_t cli_len = sizeof(cli); // 注意我们虽然 不关注 accept 中cli返回信息但是 其参数不能为0 // 否则出现错误 Bad address usersockfd = accept(listenfd, (struct sockaddr*)&cli, &cli_len); if(usersockfd == -1){ if(errno == EAGAIN || errno == EWOULDBLOCK){ //we have processed the connection break;// no new client }else{// accept failed perror("accept"); break; } } // 设置为非阻塞 ret = set_socket_noblock(usersockfd); if(ret < 0) break; // 连接被代理的远程的主机,其中isosockfd为与远程主机连接返回的套接字 if((isosockfd = connect_isolate(usersockfd, argv)) < 0) errout("connect isolate error"); ret = set_socket_noblock(isosockfd); if(ret < 0) break; //设置用于读操作的文件描述符(用户端和代理服务器通信的套接字 usersockfd) ev.data.fd = usersockfd; ev.events = EPOLLIN | EPOLLET; //注册ev ret = epoll_ctl(efd, EPOLL_CTL_ADD, usersockfd, &ev); if(ret < 0) errout("epoll ctl error"); //设置用于读操作的文件描述符(代理服务器和远程主机通信的套接字 isosockfd) ev.data.fd = isosockfd; ev.events = EPOLLIN | EPOLLET; ret = epoll_ctl(efd, EPOLL_CTL_ADD, isosockfd, &ev); if(ret < 0) errout("epoll ctl error"); // 把这对套接字加入到 map中,这样我们只有知道其中的一个套接字就能很快的找到另一个套接字,从而 // 通过代理服务器 把信息从客户端和远程主机间进行转发。 sockfd_map[usersockfd] = isosockfd; sockfd_map[isosockfd] = usersockfd; } continue; } else { /* We have data on the fd waiting to be read. Read and display it to the other end. We must read whatever data is available completely, as we are running in edge-triggered mode and won't get a notification again for the same data.*/ int done = 0; while (1) { ssize_t count; char buf[2048]; //read the send data count = read(pevents[i].data.fd, buf, sizeof(buf)); if(count ==-1){ /* If errno == EAGAIN, that means we have read all data. So go back to the main loop. */ if(errno != EAGAIN){ perror ("read"); done = 1; } break; } else if (count == 0){ /* End of file. The remote has closed the connection. */ done = 1; break; } // count >0, 通过代理服务器的中转把信息发到另一端 write(sockfd_map[pevents[i].data.fd], buf, count); if (ret == -1){ perror ("write"); abort (); } } if (done){ /* Closing the descriptor will make epoll remove it from the set of descriptors which are monitored. */ fprintf (stderr, "Closed connection on descriptor %d\n", pevents[i].data.fd); close(pevents[i].data.fd); close(sockfd_map[pevents[i].data.fd]); /* 从map 中把无用的描述符从epoll专用的文件描述符中删去 */ sockfd_map.erase(pevents[i].data.fd); sockfd_map.erase(sockfd_map[pevents[i].data.fd]); } } } }
proxy将与远端主机的相应服务端口(由用户在命令行参数中指定)建立连接,并负责传递用户主机和远端主机之间交换的数据。
在proxy函数中我们首先定义了是我们所需要的监听套接字、服务端、客户端监听套接字,epoll相关的结构变量。程序先将从main函数传来的命令行参数argv进行解析。把服务端套接口地址结构serv的所有成员清零,通过传进的参数proxy_port设置serv的端口号,一切正常之后我们把此程序转变为守护进程 在系统后台运行,通过调用daemonize 函数实现,此函数后面再做讨论。
套接字和套接字地址结构定义:
这段主程序是一段典型的服务器/客户端程序。网络通讯最重要的就是套接字的使用,在程序中我们对套接字描述符进行了相应的定义。
创建服务器端监听套接字:
下面就是建立一个服务器的详细过程。服务器程序的第一个操作是创建一个套接字。这是通过调用函数socket()来实现的。socket()函数的具体描述
#include<sys/types.h> #include<sys/socket.h> int socket(int domain, int type, intprotocol);
在通常情况下,首先要将描述服务器信息的套接字地址结构清零,然后在地址结构中填入相应的内容,准备接受客户机送来的连接建立请求。这个清零操作可以用多种字节处理函数来实现,例如bzero()、bcopy()、memset()、memcpy()等,以字母"b"开始的两个函数是和BSD系统兼容的,而后面两个是ANSIC提供的函数。这段代码中使用的bzero()其描述为:void bzero(void *s, int n);函数的具体操作是将参数s指定的内存的前n个字节清零。memset()同样也很常用,其描述为:void *memset(void* s, int c, size_t n);具体操作是将参数s指定的内存区域的前n个字节设置为参数c的内容。下一步就是在已经清零的服务器套接字地址结构中填入相应的内容。Linux系统的套接字是一个通用的网络编程接口,它应该支持多种网络通信协议,每一种协议都使用专门为自己定义的套接字地址结构(例如TCP/IP网络的套接字地址结构就是struct sockaddr_in)。
不过为了保持套接字函数调用参数的一致性,Linux系统还定义了一种通用的套接字地址结构:
-----------------------------------------------------------------
<linux/socket.h>
struct sockaddr
{
unsigned short sa_family; /* address type */
char sa_data[14]; /* protocol address */
}
-----------------------------------------------------------------
其中sa_family意指套接字使用的协议族地址类型,对于我们的TCP/IP网络,其值应该是AF_INET,sa_data中存储具体的协议地址,不同的协议族有不同的地址格式。这个通用的套接字地址结构一般不用做定义具体的实例,但是常用做套接字地址结构的强制类型转换,如我们经常可以看到这样的用法:
bind(sockfd,(struct sockaddr *) &servaddr,sizeof(servaddr))
用于TCP/IP协议族的套接字地址结构是sockaddr_in,其定义为:
<linux/in.h> struct in_addr { __u32 s_addr; }; struct sochaddr_in { short int sin_family; unsigned short int sin_port; struct in_addr sin_addr; /*This part has not been taken into use yet*/ unsigned char_ _ pad[_ _ SOCK_SIZE__- sizeof(short int) -sizeof(unsigned short int) - sizeof(struct in_addr)]; }; #define sin_zero_ - pad
其中sin_zero成员并未使用,它是为了和通用套接字地址struct sockaddr兼容而特意引入的。在编程时,一般都通过bzero()或是memset()将其置零。其他成员的设置一般是这样的:
servaddr.sin_family = AF_INET;
表示套接字使用TCP/IP协议族。
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
设置服务器套接字的IP地址为特殊值INADDR_ANY,这表示服务器愿意接收来自任何网络设备接口的客户机连接。htonl()函数的意思是将主机顺序的字节转换成网络顺序的字节。
servaddr.sin_port = htons(PORT);
设置通信端口号,PORT应该是我们已经定义好的。在本例中servaddr.sin_port = proxy_port;这是表示端口号是函数的返回值proxy_port。
另外需要说明的一点是,在本例中,我们并没有看到在预编译部分中包含有<linux/socket.h>和<linux/in.h>这两个头文件,那是因为这两个头文件已经分别被包含在<sys/socket.h >和<sys/types.h>中了,而且后面这两个头文件是与平台无关的,所以在网络通信中一般都使用这两个头文件。
#include <sys/types.h> #include <sys/socket.h> int bind(int sockfd, struct sockaddr *addr, int addrlen);
#include <sys/socket.h> int listen(int sockfd, int backlog);
#include <sys/socket.h> int accept(int sockfd, struct sockaddr *addr, int *addrlen);
// one or more connections for(; ;){ socklen_t cli_len = sizeof(cli); // 注意我们虽然 不关注 accept 中cli返回信息但是 其参数不能为0 // 否则出现错误 Bad address usersockfd = accept(listenfd, (struct sockaddr*)&cli, &cli_len); if(usersockfd == -1) { if(errno == EAGAIN || errno == EWOULDBLOCK) { //we have processed the connection break;// no new client }else{// accept failed perror("accept"); break; }
当服务器与客户机建立连接以后,就可以处理客户机的请求了。一般情况下服务器程序都要创建一个子进程用于处理客户机请求;而父进程则继续监听,时刻准备接受其它客户机的连接请求。而我们的proxy_epoll程序不是采用这种方式。我们通过epoll模型来处理客户机数据。这样我们不需要每当有客户机来我们就fork一个子进程来处理,这样是比较浪费资源的(当然创建子进程也是一种重要的方法建议学习下)。其中的epoll模型采用非阻塞非忙轮询(比如select,polll)的方式。
为什么引入epoll:
首先介绍下阻塞的概念:比如某个时候你在等快递,但是你不知道快递什么时候过来,而且此时你只能有了这份快递后才能干别的事情;那么你在快递到来前只能等待 或许你只能去睡觉,因为你知道快递把货送来时一定会给你打个电话(假定一定能叫醒你)。可以看到阻塞的方式把你阻在那了。
阻塞忙轮询:接着上面等快递的例子,如果用忙轮询的方法,那么你需要知道快递员的手机号,然后每分钟给他挂个电话:“你到了没?”
很明显一般人不会用第二种做法,不仅显很无脑,浪费话费不说,还占用了快递员大量的时间。 大部分程序也不会用第二种做法,因为第一种方法经济而简单,经济是指消耗很少的CPU时间,如果线程睡眠了,就掉出了系统的调度队列,暂时不会去瓜分CPU宝贵的时间片了。
阻塞I/O模式下,一个线程只能处理一个流的I/O事件。如果想要同时处理多个流,要么多进程(fork),要么多线程(pthread_create),很不幸这两种方法效率都不高。再来考虑非阻塞忙轮询的I/O方式,我们发现我们可以同时处理多个流了(把一个流从阻塞模式切换到非阻塞模式再此不予讨论),阻塞模式下,内核对于I/O事件的处理是阻塞或者唤醒,而非阻塞模式下则把I/O事件交给其他对象(后文介绍的select以及epoll)处理甚至直接忽略。
为了避免CPU空转,可以引进了一个代理(select的代理、poll的代理,不过两者的本质是一样的)。这个代理比较厉害,可以同时观察许多流的I/O事件,在空闲的时候,会把当前线程阻塞掉,当有一个或多个流有I/O事件时,就从阻塞态中醒来,于是我们的程序就会轮询一遍所有的流(于是我们可以把“忙”字去掉了)。
于是,如果没有I/O事件产生,我们的程序就会阻塞在select处。但是依然有个问题,我们从select那里仅仅知道了,有I/O事件发生了,但却并不知道是那几个流(可能有一个,多个,甚至全部),我们只能无差别轮询所有流,找出能读出数据,或者写入数据的流,对他们进行操作。但是使用select,我们有O(n)的无差别轮询复杂度,同时处理的流越多,没一次无差别轮询时间就越长。
epoll的优点:
具体实际操作细节可以参考 具体实例: How to use epoll? A complete example in C
此外 还有一篇比较不错的博文:linux下epoll如何实现高效处理百万句柄的
通过以上,我们知道epoll 采用的是非阻塞模式的,因此在创建完我们的监听套接字后我们应该把这个套接字设置为非阻塞模式,在此我们就绪调用函数set_socket_noblock(listenfd),现在介绍如何把描述符变为非阻塞模式;
@set_socket_noblock
// 设置文件描述符为非阻塞 int set_socket_noblock(int fd) { int flag, ret; if((flag = fcntl(fd, F_GETFL, 0)) < 0) errout("error fcntl\n"); ret = fcntl(fd, F_SETFL, flag | O_NONBLOCK); return ret; }
继续介绍 proxy 函数, 当我们listenfd套接字通过 listen函数变为被动的监听套接字后,我们就需要调用函数epoll_create1 生成用于处理accept的epoll专用的文件描述符,并把 监听套接字注册为epoll事件。然后就可以通过epoll_wait等待epoll事件的发生。nready > 0说明有事件发生,先对事件 类型pevents[i].events 进行判断 进行错误处理。然后通过pevents[i].data.fd来判断是否为 listenfd,是listenfd说明有新的连接,然后我们设置这个新的套接字(用于代理服务器和客户进行通信的套接字)为非阻塞,然后我们的代理服务器与远程主机进行连接,这就需要调用函数connect_isolate,下面介绍下 connect_isolate函数。当完成连接远程主机后我们就需要注册相关的时间,一遍epoll模型检测有新的事件发生。 如果pevents[i].data.fd不为监听套接字 listenfd,则说明我们有数据在描述符pevents[i].data.fd上,此时我们需要做的就是把此描述符上的所有数据读完(因为我们用的是edge-triggered mode),然后把读完的数据发送给另一端,这就起到代理服务器的真正的作用了。
此处可以做个图**
@connect_isolate
int connect_isolate(int usersockfd, char *argv[]) { int i, len; int isosockfd = -1, connstat = 0; struct hostent *hostp; // host entry struct servent *servp; char buf[64]; char isolate_host[64]; char service_name[32]; strcpy(isolate_host, argv[2]); strcpy(service_name, argv[3]); struct sockaddr_in hostaddr; bzero(&hostaddr, sizeof(struct sockaddr_in)); hostaddr.sin_family = AF_INET; // parse the isolate if( inet_pton(AF_INET, isolate_host,&hostaddr.sin_addr) != 1){ if((hostp = gethostbyname(isolate_host)) != NULL) bcopy(hostp->h_addr, &hostaddr.sin_addr, hostp->h_length); else return -1; } if((servp = getservbyname(service_name, TCP_PROTO)) != NULL) hostaddr.sin_port = servp->s_port; else if(atoi(service_name) >0) hostaddr.sin_port = htons(atoi(service_name)); else return -1; // open a socket to connect isolate host if((isosockfd = socket(AF_INET, SOCK_STREAM, 0)) <0) return -1; len = sizeof(hostaddr); // attempt a connection connstat = connect(isosockfd, (struct sockaddr*)&hostaddr, len); switch(connstat){ case 0: break; case ETIMEDOUT: case ECONNREFUSED: case ENETUNREACH: strcpy(buf, strerror(errno)); strcat(buf,"/r/n"); write(usersockfd,buf,strlen(buf)); close(usersockfd); return -1; /*die peacefully if we can't establish a connection*/ default: return -1; } return isosockfd; }
函数connect()的定义为:
-----------------------------------------------------------------
#include <sys/types.h>
#include <sys/socket.h>
int connect(int sockfd, struct sockaddr *servaddr, int addrlen);
-----------------------------------------------------------------
参数sockfd是调用函数socket()返回的套接字描述符,参数servaddr指向远程服务器的套接字地址结构,参数addrlen指定这个套接字地址结构的长度。函数connect()执行成功时返回"0",如果执行失败则返回"-1",并将全局变量errno设置为相应的错误类型。在例程中的switch()函数调用中对以下三种出错类型进行了处理: ETIMEDOUT、ECONNREFUSED和ENETUNREACH。这三个出错类型的意思分别为:ETIMEDOUT代表超时,产生这种情况的原因有很多,最常见的是服务器忙,无法应答客户机的连接请求;ECONNREFUSED代表连接拒绝,即服务器端没有准备好的倾听套接字,或是没有对倾听套接字的状态进行监听;ENETUNREACH表示网络不可达。
hostaddr的所有成员清零,然后将成员hostaddr.sin_family设置为TCP/IP协议族标志AF_INET。下面就可将命令行的另外两个参数<remote_host>和<service_port>传进行hostaddr的两个成员hostaddr.sin_port和hostaddr.sin_addr了。这里我们用到了两个局部变量struct hostent *hostp和struct servent *servp来传递参数信息。struct hostent的详细描述为:
-----------------------------------------------------------------------------------
struct hostent {
char *h_name;
char **h_aliases;
函数执行成功时返回非零,转换结果存入指针inp指向的in_addr结构。这个结构定义我们在前面的文章里已经介绍过了。如果参数cp指向的IP地址不可用,则返回"0"。这就避免发生inet_addr()那样的问题。这我的实现里我们使用了最新的函数inet_pton()来实现的,它支持IPv6协议。
@daemonize
一般服务器程序在接收客户机连接请求之前,都要创建一个守护进程。守护进程是linux/Unix编程中一个非常重要的概念,因为在创建一个 守护进程的时候,我们要接触到子进程、进程组、会晤期、信号机制以及文件、目录、控制终端等多个概念,因此详细地讨论一下守护进程,对初学者学习进程间关系是非常有帮助的。下面就是例程中的daemonize()函数:
// 守护进程 void daemonize(int listenfd) { pid_t pid; // 屏蔽控制终端操作信号I/O stop signal signal(SIGTTOU, SIG_IGN); signal(SIGTTIN, SIG_IGN); signal(SIGTSTP, SIG_IGN); // 重设文件创建掩模 umask(0); // 使守护进程后台运行 if((pid = fork()) <0) errout("fork error\n"); else if(pid != 0)//父进程终止运行;子进程过继给init进程,其退出状态也由init进程处理,避免了产生僵死进程 exit(0); // 脱离控制终端,登录会话和进程组,调用setsid()使子进程成为会话组长 setsid(); // 重定向标准输入输出 redirect_stdIO("/dev/null", LOG_FILE, LOG_FILE);//重定向标准输入输 // 改变当前工作目录 chdir("/"); /* close any open file descriptors */ int fd, fdtablesize; // fd切忌从3开始,想想为什么? for(fd = 3, fdtablesize = getdtablesize(); fd < fdtablesize; fd++) if (fd != listenfd) close(fd); // signal(SIGCLD,(sigfunc *)reap_status); }
此函数的作用就是创建一个守护进程。在Linux系统中,如果要将一个普通进程转换成为守护进程,并且我们可以在系统文件/tmp/daemon.log 中查看代理服务器的日志信息。必须要执行下面的步骤:
1.调用函数fork()创建子进程,然后父进程终止,保留子进程继续运行。之所以要让父进程终止是因为,当一个进程是以前台进程方式由shell启动时,在父进程终止之后子进程自动转为后台进程。另外,我们在下一步要创建一个新的会晤期,这就要求创建会晤期的进程不是一个进程组的组长进程。当父进程终止,子进程运行,这就保证了进程组的组ID与子进程的进程ID不会相等。
函数fork()的定义为:
-----------------------------------------------------------------
#include <sys/types.h>
#include <unistd.h>
pid_t fork(void);
-----------------------------------------------------------------
该函数被调用一次,但是返回两次,这两次返回的区别是子进程的返回值为"0",而父进程的返回值为子进程的ID。如果出错则返回"-1"。
2.保证进程不会获得任何控制终端。通常的做法是调用函数setsid()创建一个新的会晤期。setsid()的详细描述为:
-----------------------------------------------------------------
#include <sys/types.h>
#include <unistd.h>
pid_t setsid(void);
-----------------------------------------------------------------
第一步的操作已经保证调用此函数的进程不是进程组的组长,那么此函数将创建一个新的会晤,其结果是:首先,此进程变成该会晤期的首进程(session leader,系统默认会晤期的首进程是创建该会晤期的进程)。而且,此进程是该会晤期中的唯一进程。然后,此进程将成为一个新的进程组的组长进程,新进程组的组ID就是该进程的进程ID。最后,保证此进程没有控制终端,即使在调用setsid()之前此进程拥有控制终端,在创建会晤期后这种联系也将被解除。如果调用该函数的进程为一个进程组的组长,那么函数将返回出错信息"-1"。
当然我们还有其他的办法让进程无法获得控制终端,就象例程中所做的那样,
-----------------------------------------------------------------
if ((fd = open("/dev/tty",O_RDWR)) >= 0) {
ioctl(fd,TIOCNOTTY,NULL);
close(fd);
}
-----------------------------------------------------------------
其中/dev/tty是一个流设备,也是我们的终端映射。调用close()函数将终端关闭。
3.信号处理。一般是要忽略掉某些信号。这里就涉及到信号的概念了。信号其实相当于软件中断,Linux/Unix下的信号机制提供了一种处理异步事件的方法,终端用户键入引发中断的键,或是系统异常发出信号,这都会通过信号处理机制终止一个或多个程序的运行。
不同情况下引发的信号是不同的。不过所有的信号都有自己的名字,所有的名字都是以"SIG"开头的,只是后面有所不同,我们可以通过这些名字了解到系统中到底发生了些什么事。
当信号出现时,我们可以要求系统进行以下三种操作:
◇忽略信号。大多数信号都是采取这种方式进行处理的,在例程中我们就可以见到这种用法。但值得注意的是有两个例外,那就是对SIGKILL和SIGSTOP信号不能做忽略处理。
◇捕捉信号。这是一种最为灵活的操作方式。这种处理方式的意思就是,当某种信号发生时,我们可以调用一个函数对这种情况进行相应的处理。最常见的情况就是,如果捕捉到SIGCHID信号,则表示子进程已经终止,然后可在此信号的捕捉函数中调用waitpid()函数取得该子进程的进程ID以及它的终止状态。在我们这段例程中,就有这种用法的一个实例。还有就是如果进程创建了临时文件,那么就要为进程终止信号SIGTERM编写一个信号捕捉函数来清除这些临时文件。
◇执行系统的默认动作。对绝大多数信号而言,系统的默认动作都是终止该进程。
在Linux下,信号有很多种,我在这里就不一一介绍了,如果想详细地对这些信号进行了解,可以查看头文件<sigal.h>,这些信号都被定义为正整数,也就是它们的信号编号。在对信号进行处理时,必须要用到函数signal(),此函数的详细描述为:
-----------------------------------------------------------------
#include <signal.h>
void (*signal (int signo, void (*func)(int)))(int);
-----------------------------------------------------------------
其中参数signo为信号名,参数func的值根据我们的需要可以是以下几种情况:(1)常数SIG_DFL,表示执行系统的默认动作。(2)常数SIG_IGN,表示忽略信号。(3)收到信号后需要调用的处理函数的地址,此信号捕捉程序应该有一个整型参数但是没有返回值。signal()函数返回一个函数指针,而该指针指向的函数应该无返回值(void),这个指针其实指向以前的信号捕捉程序。
下面回到我们的daemonize()函数上来。这个函数在创建守护进程时忽略了三个信号:
signal(SIGTTOU,SIG_IGN);
signal(SIGTTIN,SIG_IGN);
signal(SIGTSTP,SIG_IGN);
这三个信号的含义分别是:SIGTTOU表示后台进程写控制终端,SIGTTIN表示后台进程读控制终端,SIGTSTP表示终端挂起。
4.关闭不再需要的文件描述符,并为标准输入、标准输出和标准错误输出打开新的文件描述符(也可以继承父进程的标准输入、标准输出和标准错误输出文件描述符,这个操作是可选的)。在我们这段例程中,因为是代理服务器程序,而且是在执行了listen()函数之后执行这个daemonize()的,所以要保留已经转换成功的倾听套接字,所以我们可以见到这样的语句:
if (fd != servfd)
close(fd);
5.调用函数chdir("/")将当前工作目录更改为根目录。这是为了保证我们的进程不使用任何目录。否则我们的守护进程将一直占用某个目录,这可能会造成超级用户不能卸载一个文件系统。
6.调用函数umask(0)将文件方式创建屏蔽字设置为"0"。这是因为由继承得来的文件创建方式屏蔽字可能会禁止某些许可权。例如我们的守护进程需要创建一组可读可写的文件,而此守护进程从父进程那里继承来的文件创建方式屏蔽字却有可能屏蔽掉了这两种许可权,则新创建的一组文件其读或写操作就不能生效。因此要将文件方式创建屏蔽字设置为"0"。
在daemonize()函数的最后,我们可以看到这样的信号捕捉处理语句:
signal(SIGCLD,(Sigfunc *)reap_status);
这不是创建守护进程过程中必须的一步,它的作用是调用我们自定义的reap_status()函数来处理僵死进程。在这个程序里由于我们不是不需要这一步。
关于linux上守护进程的编写可以到我的这篇博客查看更详细的信息。
最后关于本博客的源码 请移步至: https://github.com/ustcdane/proxy_epoll ,谢谢!
声明:
最后终于写完了, 这篇博客是在前人 的基础之上加上自己的理解把程序改为 基于 epoll模型的代理服务器,其中参考了一篇名为 《proxy源代码分析——谈谈如何学习Linux网络编程》的文章(你可以google一下很容易就找到了),这篇文章写的确实很不错,以至于博客中有一部分直接引用作者的原话,在此感谢原作者。