标签:
到今天在公司呆了8个月,完成从校园人到职场人的转身。出身社会,感触颇多,一切身不由己。在工作中快乐过、焦虑过、失望过,到最后的心凉,这一路下来成长不少。大学毕业设计涉及网络知识,当时学习了一些基础的网络知识,工作不久,转到新的项目组hms做的产品IPTV刚好和网络相关,最近几个月一直在看《linux高性能服务器编程》,在网上也看了不少文章,一直想写篇总结。
图1 c/s架构通信
图1是一个简化的tcp通信过程,左侧为“服务器端”,右侧为“客户端”,tcp协议本身并没有定义服务端、客户端这些概念,因为大量的资源掌握在通信的左侧,请求都指向它,也就有了服务端、客户端这些概念。在网络发展的初期,网络传输最多的是文本文件,不像现在大量的动态图、视频等多媒体资源。客户端建立连接后,提交一个资源请求,服务端收到请求后解析请求的资源,然后发送客户端请求的资源,客户端收到请求的资源后,断开连接,一次通信结束。文本文件的内容长度不大,一般在KB以下,服务端发送数据的次数取决于服务器端的链路MTU,早期的施乐X.25是576,而以太网则是1500字节,发送的次数也就1、2次。随着网络的普及、多媒体技术的发展、浏览器的升级、新协议的推出,早期服务器的架构已经不能满足要求,服务器的模型也一直在衍化。下面介绍几种常见的服务器模型。
迭代模型算是最早期的服务器模型,其核心实现是每来一个用户,然后为这个用户服务到底,过程中不接受任何新的用户请求,单台服务器就服务一个用户,其流程图如图1。
核心代码:
bind(listenfd);
listen(listenfd);
for( ; ; )
{
connfd = accept(listenfd, ...); //接受客户端来的连接
while(user_oline)
{
read(connfd,recv_buf, ...); //从客户端读取数据
release_request(recv_buf); //解析客户请求
write(connfd,send_buf, …) //发送数据到客户端
}
close(connfd)
}
这种模式最大的问题是几个主要的操作都是阻塞的,譬如accept,如果一直没有用户过来,那么进程一直堵在这儿。还有read操作,前面假设的用户建立连接后发送资源请求,但是用户不发送呢?如果打开了tcp保活检测,那也是几分钟后的事才能关掉这个恶意连接,如果没有打开tcp保活检测,那也要设置一个连接有效时间。即使这样,服务器在中间这几分钟也是完全空闲的,但是不能接受新的用户连接。
为了解决上述操作阻塞,不能接受新用户连接,使用多进程模型。此模型的核心思想是在主进程接受用户连接,子进程中处理业务,这样就不会阻塞新用户连接。
图2 多进程模型
核心代码:
bind(listenfd);
listen(listenfd);
for( ; ; )
{
connfd = accept(listenfd, ...); //开始接受客户端来的连接
pid = fork();
switch( pid )
{
case -1 :
do_err ();
break;
case 0 : // 子进程
client_handler(user_info);
break ;
default : // 父进程
close(connfd);
continue ;
}
}
voidclient_handler(user_info)
{
while( )
{
read(connfd,recv_buf,...); //从客户端读取数据
dosomthingonbuf(recv_buf); //解析用户请求
write(connfd,send_buf) //发送数据到客户端
}
shutsown(connfd)
}
此种模式的劣势会在多线程中给出。
Linux上面线程又称为轻量级进程,它和主线程共享整个进程的数据,线程切换的开销远小于进程。多线程模型的核心思想是每来一个用户连接就为用户创建一个线程,其流程图如图2,只需将fork改为pthread_create即可。
核心代码:
bind(listenfd);
listen(listenfd);
for( ; ; )
{
connfd = accept(listenfd, ...); //开始接受客户端来的连接
ret = pthread_create( , worker, , user_info);
}
void worker(user_info)
{
while( )
{
read(connfd,recv_buf,...); //从客户端读取数据
dosomthingonbuf(recv_buf); //解析用户请求
write(connfd,send_buf) //发送数据到客户端
}
shutsown(connfd)
}
多进程模型、多线程模型的劣势:
1、进程、线程的创建、销毁在某些时候会造成很大的消耗,举个简单的例子:现在终端设备观看视频主流的传输协议是基于http协议的hls,华为IPTV最新版本单台服务器出流60G,在hls短连接的情况下出流打8折48G,按照700K的码率,那么用户数接近7W,服务器的caps按照2W计算。按照多线程、多进程的的模型,1s建立、销毁几万的线程,服务器根本扛不住。所以大都的设计都会实现进程、线程池,减少这部分的开销。进程、线程池不能避免的是资源的抢占,在进程池中多用信号量、共享内存实现资源的分配,在线程池中多用互斥锁或者条件变量实现资源分配。
2、多线程情况下,如果一个线程出现问题,可能导致所在进程挂掉。
3、在多核情况下没有意义的多进程、多线程。如前面所说,一台服务器出流60G,假如用户是标清2M码率,那么在线用户数就是30000。以华为最新架构的RH5288 V3配置Intel 2658 48核做硬件,那么单个核上面的进程、线程数是30000/48=625。一个核上面跑这么多功能完全相同的进程、或者线程是完全没有任意意义的,这还是默认单台服务器能跑这么多进程、线程。说白一点,一个用户就使用一个进程、线程是绝对不行的。
撇开框架不说,select/poll就是用来解决上述一个用户就使用一个进程、线程的问题,select/poll可以在一个进程、线程监听多个文件句柄。
代码:
bind(listenfd);
listen(listenfd);
FD_ZERO(&set);
FD_SET(listenfd, &aset);
for( ; ; )
{
//循环添加所有文件描述符
for()
select(...);
if (FD_ISSET(listenfd, &rset));
{
connfd = accept();
user_info [] = connfd;
FD_SET(connfd, &set);
}
else if ...
//循环检测文件描述符
for( ; ; )
{
fd = user_info[i].fd;
if (FD_ISSET(fd , &rset))
dosomething();
else if…
}
}
Select监听多个文件描述符,set的本质时候一个整形数组,数组的每一比特位表示一个文件描述符,select可以监听文件描述符上的读事件、写事件、异常事件,当文件描述符上发生其中某件事,系统调用就会把相应的位置1。Select最让人诟病的有两点,一是每次调用select之前都要将文件描述符添加到数组中,暂且不说依次遍历添加的时间,把数据从用户态拷贝到内核态是很消耗性能的。二是select调用返回必须再次遍历数组,查看文件描述符是否有事件产生,又是一个O(n)的操作。其实还有一点,select操作用户的数据必须单独保存,在select调用中无法保存用户数据。
bind(listenfd);
listen(listenfd);
AddToPoll(listenfd);
for( ; ; )
{
Number = poll();
if (event.revents &POLLIN && fd == listenfd)
{
connfd = accept();
event [] = connfd;
AddToPoll(connfd);
}
else if …
//循环检测文件描述符
for( ; ; )
{
fd = event.fd;
if (event.revents&POLLIN)
dosomething();
else if…
}
}
poll 的实现机制是将文件描述符以及对此文件描述符感兴趣的事件写入一个结构体,poll调用返回,操作系统会把文件描述符发生过的事件写入一个变量中。poll较select的优化之处在于不用每次拷贝文件描述符、将事件都写入了一个变量,不像select使用三个变量,poll调用仅能保存文件描述符。但是poll调用返回也必须再次遍历数组,这也是一个O(n)操作。
linux IO复用使用最多就是epoll,epoll的实现同poll类似,但是它做了两点改进。一是epoll调用中使用的结构体能够保存用户数据(不仅仅),二是epoll返回实际发生了事件的文件描述符个数,这些对应的事件都写入返回数组。这对有些场景是很又意义的,譬如RTSP协议在使用UDP传送数据的使用,其信令数据使用tcp传输,信令数据较业务数据少得多,一般较长时间才会有一个信令交互。以60G的场景,用户数据2M码率,那么需要监听的文件描述符为30000,某个时刻一个文件描述符产生了事件,如果是poll调用则需遍历长度为30000的数组,而epoll只需要1次。
代码:
bind(listenfd);
listen(listenfd);
AddToEpoll(listenfd);
for( ; ; )
{
Number = Epoll_wait ();
if (event.events &EPOLLIN && fd == listenfd)
{
connfd = accept();
event [].data.fd = connfd;
AddToEpoll(connfd);
}
else if …
//循环检测文件描述符
for( ; ; )
{
fd = event.data.fd;
if (event.events&EPOLLIN)
dosomething();
else if…
}
}
显然有了IO复用这一特性,原有的多进程、多线程模式设计流程已经不适合。前面的所有流程中,接受新用户连接(accept)这一操作都是在主进程或者主线程中完成中,但是在有些时候单进程、单线程处理就会遇到瓶颈,在前面的短连接例子中,单个进程、线程的caps是不到1s 2W的。关于主进程、线程和工作进程、线程的分工必须明确,到底谁负责连接、谁负责业务处理、谁负责读写。
为了解决accept瓶颈问题,有些模式是把处理accept放到每个进程、线程中,还有些公司在linux上开发内核模块,使用端口NAT技术,每一个核监听一个单独的端口。好消息是linux 3.7以上的版本支持 PortReuse这一特性,多个进程可以同时监听一个端口又不会产生惊群效应。
模型如图3,工作线程能接受新用户连接,主进程在listen之后创建多个进程。
图3
核心代码:
bind(listenfd);
listen(listenfd);
//一般创建同cpu个数个子进程
Master = 1;
for( ;<cpu_number; )
{
pid = fork();
assert(pid >= 0);
if(pid > 0)
{
continue;
}
else
{
master =-1;
break;
}
}
if(1 == master)
{
run_master();
}
else
{
run_worker();
}
void run_worker()
{
epoll_create();
for( ; ; )
{
Number = Epoll_wait();
If (event.events &EPOLLIN && fd == listenfd)
{
connfd= accept();
event[].data.fd = connfd;
AddToEpoll(connfd);
}
else if …
//循环检测文件描述符
for( ; ; )
{
fd = event.data.fd;
if (event.events &EPOLLIN)
dosomething();
else if…
}
}
可以看到主进程创建了多个字进程,然后在子进程创建自己的epoll文件描述符,有些实现是在主进程epoll创建后才fork,个人不是很喜欢此种做法。越来越火的nginx也使用了类似的模式,为了避免惊群效应,其用共享内存实现了一把互斥锁,在调用accept之前必须先获取到此互斥锁。
《linux 高性能服务器编程》写了一种免锁的工作进程accept方式,具体的实现是子进程epoll中不加入监听句柄。在进程创建初期,创建管道,父进程epoll监听listenfd,但是不做accept操作,而是通过管道通知某个子进程去accept。
图4 免锁的进程accept
核心代码
void run_worker()
{
epoll_create();
for( ; ; )
{
Number = Epoll_wait();
//如果父进程通过管道通知了,就去accept
If (event.events &EPOLLIN && fd ==pipefd[0])
{
connfd= accept();
event[].data.fd = connfd;
AddToEpoll(connfd);
}
else if …
//循环检测文件描述符
for( ; ; )
{
fd = event.data.fd;
if (event.events &EPOLLIN)
dosomething();
else if…
}
}
从诸多的实际应用来看,使用线程的时候很少有在子线程中做accept操作,一般的做法是主线程只做accept操作,然后子线程负责数据的读写。这样编程也是最简单的,但是极易出现主线程accept的瓶颈。
图5 多线程IO复用
如图5所示,在主线程accept之前,会创建一些线程和对应数量的epoll,为每一个线程分配一个epoll。主线程接受到新用户后,因为是同一进程,直接将用户添加到某个线程的epoll中。
核心代码:
bind(listenfd);
listen(listenfd);
//创建同cpu个数个进程
for( ;<cpu_number; )
{
pthread_create( , worker, , );
}
//创建同cpu个数加1个eopll
for( idx = 0;<cpu_number + 1; )
{
thread_epoll[idx] = epoll_create(number);;
}
//将监听文件描述符添加到主线程epoll
epoll_ctl( , listenfd,)
while( 1)
{
Number = Epoll_wait();
If (event.events &EPOLLIN && fd == listenfd)
{
connfd = accept();
event [].data.fd = connfd;
//轮询或者某种算法,锁定到某个线程
AddToEpoll(connfd);
}
else if …
{
}
}
void* worker()
{
for( ; ; )
{
Number = Epoll_wait();
//循环检测文件描述符
for( ; ; )
{
fd = event.data.fd;
if (event.events &EPOLLIN)
{
dosomething();
}
elseif…
}
}
Epoll 线程池也有另外一种做法,主线程负责accept,负责分发任务,它会把用户的scoket写入链表,然后多个线程链表去竞争这个链表,得到链表的线程去除头节点然后释放所有权,工作线程只有业务处理,没有epoll操作。这种做法有两个缺点,一是主线程既要处理用户连接请求,又要分发任务,造成主线程忙死、子线程闲死的现象,完全没有发挥epoll和多线程的特点。
图6
标签:
原文地址:http://www.cnblogs.com/liu-song/p/5399838.html