Linux服务器程序规范
- Linux服务器程序一般都是以后台进程形式运行,后台进程又称为守护进程(daemon),其没有控制终端,不会意外接收到用户输入。守护进程的父进程通常是init进程(PID为1的进程);
- Linux服务器程序通常有一套日志系统,其至少能输出日志到文件,有的高级服务器还能输出日志到专门的UDP服务器;
- Linux服务器程序一般以某个专门的非root身份运行;
- Linux服务器程序通常是可配置的;
- Linux服务器进程通常会在启动的时候生成一个PID文件并存入/var/run记录中,以记录后台进程的PID;
- Linux服务器程序通常需要考虑系统资源和限制,以预测自身能承受多大负荷,比如进程可用文件描述符总数和内存总量等;
用户信息
在Unix进程中涉及多个用户ID和用户组ID,包括如下:
- 实际用户ID和实际用户组ID: 标识我是谁。也就是登录用户的uid和gid,比如我的Linux以simon登录,在Linux运行的所有的命令的实际用户ID都是simon的uid,实际用户组ID都是simon的gid(可以用id命令查看)。
- 有效用户ID和有效用户组ID:进程用来决定我们对资源的访问限制,一般情况下,有效用户ID等于实际用户ID,有效用户组ID等于实际用户组ID。当设置用户ID(SUID)位设置,则有效用户ID等于文件所有者的uid,而不是实际用户ID;同样,如果设置了设置-用户组-ID(SGID),则有效用户ID等于文件所有者的gid,而不是实际用户组ID;
有效用户ID为root的进程称为特权进程;
会话
一些有关联的进程组可以形成一个会话,下面函数用于创建一个会话:
#include<unistd.h>
pid_t setsid(void);
该函数不能由进程组的首领进程调用,否则产生错误。对于非首领进程,调用该函数不仅创建新会话,还有如下作用:
- 调用进程称为会话的首领,此时该进程是新会话的唯一成员;
- 新建一个进程组,其PGID就是调用进程的PID,调用进程称为该组的首领;
- 调用进程将甩开终端(如果有的话);
服务器程序后台化
后台化的步骤:
- 创建子进程,父进程退出;
- 在子进程中创建新会话(setsid());
- 改变当前目录为根目录,防止占用可卸载的文件系统;
- 重设文件权限掩码,防止继承的文件创建屏蔽字拒绝某些权限;
- 关闭文件描述符,继承的打开文件不会用到;
- 开始执行守护进程核心工作;
- 守护进程退出处理;
bool daemonize(){]
pid_t pid = fork();
if(pid < 0){
return false;
}
else if(pid > 0){
exit(0);
}
//设置文件权限掩码,当进程创建新文件时,文件的权限将是mode & 0777
umask(0);
pid_t sid = setsid();
if(sid < 0){
return false;
}
//切换工作目录
if(chdir("/") < 0){
return false;
}
close(STDIN_FILENO);
close(STDOUT_FILENO);
close(STDERR_FILENO);
//关闭其他已打开的文件描述符,代码省略
//将标准输入,标准输出,标准错误输出都重定向到/dev/null文件
open("/dev/null", O_RDONLY);
open("/dev/null", O_RDWR);
open("/dev/null", O_RDWR);
return true;
}
守护进程详细说明
事件处理模式
Reactor模式
Reactor:要求主线程只负责监听文件描述符上是否有事件发生,有的话就立即将该事件通知工作线程,除此之外,主线程不做任何其他实质性工作,读写数据,接收新的连接,以及处理客户请求均在工作线程完成。
Proactor模式
与Reactor模式不同,Proactor模式将所有I/O操作都交给主线程和内核来处理,工作线程仅仅负责业务逻辑,因此,Proactor模式更符合服务器编程框架。
使用异步I/O模型(以aio_read和aio_write)实现Proactor模式的工作流程:
1) 主线程调用aio_read函数向内核注册socket上的读完成事件,并告诉内核用户读缓冲区的位置,以及读操作完成时如何通知应用程序(以信号为例);
2) 主线程继续处理其他逻辑;
3) 当socket上的数据被读入用户缓冲区,内核将向应用程序发送一个信号,以通知应用程序数据可用;
4)应用程序预定义好的信号处理函数选择一个工作线程来处理客户请求,工作线程处理完客户请求之后,调用aio_write函数向内核注册socket上的写完成事件,并告诉内核用户缓冲区的位置,以及写操作完成时如何通知应用程序(以信号为例);
5)主线程继续处理其他逻辑;
6)当用户缓冲区的数据被写入socket之后,内核将向应用程序发送一个信号,以通知应用程序数据已经发送完毕;
7)应用程序预定义好的信号处理函数选择一个工作线程来做善后处理,比如决定是否关闭socket;
模拟Proactor模式
使用同步I/O模拟Proactor模式的一种方法,原理:主线程执行数据读写操作,读写完成之后,主线程向工作线程通知这一“完成事件”,那么从工作线程角度来看,其直接获得了数据读写的结果,接下来要做的只是对读写的结果进行逻辑处理;
工作流程:
1) 主线程往epoll内核时间表注册socket上的读就绪事件;
2) 主线程调用epoll_wait等待socket上有数据可读;
3) 当socket上有数据可读,epoll_wait通知主线程,主线程从从socket循环读取数据,知道没有更多数据可读,然后将读取到的数据封装成一个请求对象插入请求队列;
4) 睡眠在请求队列上的某个工作线程被唤醒,它获得请求对象并处理客户请求,然后往epoll内核事件表注册socket上的写就绪事件;
5) 主线程调用epoll_wait等待socket可写;
6) 当socket可写时,epoll_wait通知主线程,主线程往socket上写入服务器处理客户请求的结果;
流程图:
高效并发模式
并发编程的目的是让程序“同时”执行多个任务:
如果是计算密集型的,并发编程没有优势,反而由任务的切换导致效率降低;
如果程序是I/O密集型的,比如经常读写文件,访问数据库等,则情况就不同了,由于I/O操作的速度远没有CPU计算的速度快,所以让程序阻塞于I/O操作将浪费大量的CPU时间,如果程序有多个执行进程,则当前被I/O操作所阻塞的执行线程可主动放弃CPU(或由OS调度),并将执行权转移到其他线程,这样一来,CPU就可以用来做更加有意义的事情(除非所有线程都同时被I/O操作所阻塞),而不是等待I/O操作完成,因此CPU的利用率显著提升;
半同步半异步模式
半同步半异步模式中的同步和异步与I/O模式中的同步和异步是完全不同的概念。
- I/O模型中,同步和异步区分的是内核向应用程序通知的何种I/O事件(是就绪事件还是完成事件),以及该由谁来完成I/O操作(是应用程序还是内核);
- 并发模式中,同步指程序完全按照代码序列的顺序执行,异步值程序的执行需要系统事件来驱动;
在半同步半异步模式中,同步线程用于处理客户逻辑,异步线程用于处理I/O事件,异步线程监听到客户请求后,就将其封装成请求对象并插入请求队列中,请求队列将通知某个工作在同步模式的工作线程读取并处理该请求对象,具体选择哪个工作线程来为新的客户请求服务,取决于请求队列的设计;
半同步半反应堆模式:使用模拟的Proactor时间处理模式,主线程完成数据的读写,主线程一般会将应用程序数据、任务类型等信息封装成一个任务对象,将其(或指向任务队列的指针)插入请求队列,工作线程从请求队列中取得任务对象之后,即可直接处理之,无需读写操作了。
但有如下缺点:
- 主线程和工作线程共享请求队列,主线程往请求队列中添加任务,或者工作线程从请求队列中取出任务,都需要对请求队列加锁保护,白白消耗CPU时间;
- 每个工作线程在同一时间只能处理一个客户请求,如果客户请求数量较多,而工作线程较少,则请求队列中将堆积很多任务对象,客户端的响应速度将越来越慢,如果通过增加工作线程来解决这个问题,则工作线程的切换也将耗费大量CPU时间;
提高服务器性能的建议
影响服务器性能的首要因素是系统的硬件资源,比如CPU的个数、速度、内存的大小等;
从“软环境”提升服务器的性能,“软环境”:
- 系统的软件资源:操作系统允许用户打开的最大文件描述符的数量;
- 服务器程序本身:如何从编程的角度确保服务器的性能?
池
“浪费”服务器的硬件资源,以换取其运行效率,这就是池的概念。
池是一组资源的集合,这组资源在服务器启动之初就被完全创建好并初始化,称为静态资源分配;
当服务器进入正式运行阶段,即开始处理客户请求的时候,如果它需要相关的资源,可直接从池中获取,无须动态分配。直接从池中获取所需资源比动态分配资源的速度快的多,因为分配系统资源的系统调用时很耗时的。当服务器处理完一个客户连接之后,可以把相关的连接放入池中,无须执行系统调用释放资源。
池相当于服务器管理系统资源的应用层设施,避免了服务器对内核的频繁访问;
-
内存池:通常用于socket的接收和发送缓存;
-
进程池和线程池:可直接从其中获得一个执行实体,无须动态调用fork或pthread_create等函数创建进程和线程;
-
连接池:服务器或服务器机群内部的永久链接;
数据复制
高性能服务器应该避免不必要的数据复制,尤其是当数据复制发生在用户代码和内核之间的时候。可以使用“零拷贝”函数进行数据的复制,如sendfile等;
用户代码内部(不访问内核)的数据复制也应该避免,可以使用共享内存共享数据而不是使用管道或消息队列传递数据;
上下文切换和锁
并发程序必须考虑上下文切换的问题,即进程切换或线程切换导致的系统开销。即使是I/O密集型的服务器,也不应该使用过多的工作线程(或工作进程),否则线程间的切换将占用大量的CPU时间,服务器真正用处理业务逻辑的CPU时间的比重就显得不足了。
多线程服务器的一个优点:不同线程可以同时运行在不同的CPU上,当线程的数量不大于CPU的数目时,上下文切换就不是问题了。
并发程序需要考虑的另外一个问题是共享资源的加锁保护,锁通常被认为是导致服务器效率低下的一个因素,因为由它引入的代码不仅不处理任何业务逻辑,而且需要访问内核资源。因此,服务器如果有更好的解决方案,应该避免使用锁。当服务器必须使用锁,则可以考虑减小锁的粒度;