博客园  :: 首页  :: 新随笔  :: 联系 :: 订阅 订阅  :: 管理

linux 高性能服务器编程

Posted on 2015-08-13 16:50  bw_0927  阅读(1895)  评论(0编辑  收藏  举报

socket在创建的时候默认是阻塞的

阻塞的系统调用有:accept,send,recv,connect

非阻塞io并且数据没就绪时,这些系统调用返回-1,accept,send,recv的errno被置为EAGAIN或者EWOULDBLOCK;connect的errno被置为EINPROGRESS

很显然,在事件已经发生的情况下操作非阻塞IO,才能提高程序的效率。因此,非阻塞IO通常要和其他IO通知机制一起使用,比如IO复用和SIGIO信号

linux上常用的IO复用函数是select, poll, epoll_wait。IO复用函数本身是阻塞的,他能提高效率的原因在于他们具有同时监听多个IO事件的能力。

 

使用同步IO模型(以epoll_wait为例)实现的Reactor模式

使用异步IO模型(以aio_read和aio_write为例)实现的Proactor模式

epoll_wait()返回值:

EINTR  The call was interrupted by a signal handler before any of the requested events occurred or the timeout expired; see signal(7).

 

当安装了信号处理函数时,如果进程收到了信号,会首先调用信号处理函数,信号处理完成后返回时,epoll_wait返回-1,错误码置位EINTR。

 

 

使用同步IO模型(以epoll_wait为例)模拟出的Proactor模式

并发编程的目的是让程序“同时”执行多个任务。如果程序是计算密集型的,并发编程并没有优势,反而由于任务的切换使效率降低。但如果是IO密集型的,比如经常读写文件,访问数据库等,则情况就不同了。

并发编程主要有多进程和多线程两种方式。并发模式是指IO处理单元和多个逻辑单元之间协调完成任务的方法。 服务主要有两种并发编程模式:半同步/半异步模式(half-sync/half-async)和领导者/追随者(Leader/Followers)模式。

 

 

对于像服务器这种既要求较好的实时性,又要求能及时处理多个客户请求的应用程序,应该同时使用同步线程和异步线程来实现,即采用半同步/半异步模式来实现。

半同步/半异步模式中,同步线程用于处理客户逻辑;异步线程用于处理IO事件。异步线程监听客户请求后,就将其封装成请求对象并插入请求队列中。请求队列将通知某个工作在同步模式的工作线程来读取并处理该请求对象。

 

逻辑单元内部的一种高效编程方法:有限状态机(finite state machine)

 

 

高性能服务器需要注意的其他几个方面:池,数据复制,上下文切换和锁。

池分为多种,常见的有内存池,进程池,线程池和连接池。

连接池通常用于服务器或服务器机群的内部永久连接,例如数据库连接池。

避免数据在内核空间和用户空间的无谓复制,例如ftp服务器,无需关心文件的内容,无须把目标文件的内容完整地读入应用程序缓冲区中并调用send函数发送,而是可以使用“零拷贝函数”sendfile来直接将其发送给客户端。

进程间通信时优先考虑共享内存而不是使用管道或者消息队列。

即使IO密集型的服务器,也不应该使用过多的工作线程或进程,否则线程间的切换将占用大量的CPU时间,服务器真正用于业务逻辑处理的CPU时间的比重就显得不足了。

共享资源的加锁保护。锁通常被认为是导致服务器效率低下的一个因素。

只要程序需要同时监听多个文件描述符就可以使用IO复用技术,客户端,服务端都可以使用。

IO复用虽然能同时监听多个文件描述符,但它本身是阻塞的。并且当多个文件描述符同时就绪时,如果不采取额外的挫事,程序就只能按顺序依次处理其中的每一个文件描述符,这使得服务器看起来像是串行工作的。

如果要实现并发,只能使用多进程或多线程等编程手段。

 

socket上接收到普通数据和带外数据都将是select返回,但socket处于不同的就绪状态:前者处于可读状态,后者处于异常状态。

 

epoll:

一个socket连接在任一时刻都只被一个线程处理,可以用epoll的EPOLLONESHOT事件实现。

 Sets the one-shot behavior for the associated file descriptor.  This  means  that  after  an  event is pulled out with epoll_wait(2) the associated file
 descriptor is internally disabled and no other events will be  reported  by  the  epoll interface.  The user must call epoll_ctl() with EPOLL_CTL_MOD to
 re-arm the file descriptor with a new event mask.需要重置

listen fd上是不能注册EPOLLONESHOT事件的,否则应用程序只能处理一个客户端连接。因为后续的客户连接请求将不再触发listen fd上的EPOLLIN事件。

EPOLLONESHOT事件使用场合:      

一个线程在读取完某个socket上的数据后开始处理这些数据,而数据的处理过程中该socket又有新数据可读,此时另外一个线程被唤醒来读取这些新的数据。      

于是,就出现了两个线程同时操作一个socket的局面。可以使用epoll的EPOLLONESHOT事件实现一个socket连接在任一时刻都被一个线程处理

作用:       对于注册了EPOLLONESHOT事件的文件描述符,操作系统最多出发其上注册的一个可读,可写或异常事件,且只能触发一次。

使用:       注册了EPOLLONESHOT事件的socket一旦被某个线程处理完毕,该线程就应该立即重置这个socket上的EPOLLONESHOT事件,以确保这个socket下一次可读时,其EPOLLIN事件能被触发,进而让其他工作线程有机会继续处理这个sockt。

效果:       尽管一个socket在不同事件可能被不同的线程处理,但同一时刻肯定只有一个线程在为它服务,这就保证了连接的完整性,从而避免了很多可能的竞态条件。

 

服务器程序必须处理或至少忽略一些常见的信号,以免异常终止。

信号处理函数应该是可重入的,否则很容易引发一些竞态条件。

信号的默认处理方式有:结束进程(Term),忽略信号(Ign),结束进程并生成核心转储文件(Core),暂停进程(Stop),继续进程(Cont)

与网络编程关系密切的几个信号:SIGHUP, SIGPIPE, SIGURG

如果程序在执行处于阻塞状态的系统调用时接收到信号,并且我们为该信号设置了信号处理函数,则默认情况下系统调用将被中断,并且errno被设置为EINTR。我们可以

使用sigaction函数为信号设置SA_RESTART标志以自动重启被该信号终端的系统调用。

信号掩码:设置进程的信号掩码(确切的说是在进程原有的信号掩码基础上增加信号掩码),以指定哪些信号不能发送给本进程。

进程的信号掩码可通过sigaction结构体的sa_mask成员来设置,或者通过函数

int sigprocmask(int how, const sigset_t *set, sigset_t *oldset); 来设置

 

被挂起的信号:

设置进程信号掩码后,被屏蔽的信号将不能被进程接收。如果给进程发送一个被屏蔽的信号,则操作系统将把该信号设置为进程的一个被挂起的信号。如果我们取消对

挂起信号的屏蔽,则它能立即被进程接收到。可通过以下函数获取当前进程被挂起的信号集。

int sigpending(sigset_t *set);

fork调用产生的子进程将继承父进程的信号掩码,但具有一个空的挂起信号集。

 信号是一种异步事件:信号处理函数和程序的主循环是两条不同的执行路线。很显然,信号处理函数需要尽可能快地执行完毕,以确保信号不被屏蔽(为了避免一些竞态条件,

信号在处理期间,系统不会再次触发它)太久。一种典型的解决方案是:把信号的主要处理逻辑放到程序的主循环中,当信号处理函数被触发时,它只是简单地通知主循环程序接收到信号,并把

信号值传递给主循环,主循环再根据收到的信号值执行目标信号对应的逻辑代码。信号处理函数通常使用管道来将信号传递给主循环:信号处理函数往管道的写端写入信号值,主循环则从管道的

读端读出该信号值。那么主循环怎么知道官道上何时有数据可读呢?这很简单,我们只需要使用IO复用系统调用来监听管道的读端文件描述符上的可读事件。如此一来,信号事件就能和其他IO事件

一样被处理,即统一事件源。

很多优秀的IO框架库和后台服务器程序都统一处理信号和IO事件,比如libevent 和 xinetd.

strace 命令能跟踪程序执行时调用的系统调用和接收到的信号。

strace -p 1234 & > log

 

On POSIX-compliant platforms, SIGHUP is a signal sent to a process when its controlling terminal is closed.

初衷是为了在终端挂断时告诉终端控制进程这个事件,在守护进程中,通常用来重读配置文件

说点实际的, 当你ssh到一台机器, 然后开个vim, 当你关闭这个ssh会话的时候vim 就会收到 SIGHUP

当挂起进程的控制终端时,SIGHUP信号将被触发。对于没有控制终端的网络后台程序而言,他们通常利用SIGHUP信号来强制服务器重新读取配置文件。

 

SIGPIPE

默认情况下,往一个读端关闭的管道或socket连接中写数据将引发SIGPIPE信号。我们需要在代码中捕获并处理该信号,或者至少忽略它,因为程序接收到SIGPIPE的默认行为是结束进程,而我们绝对不希望因为

错误的写操作而导致程序退出。引起SIGPIPE信号的写操作将设置errno为EPIPE

我们可以使用send函数的MSG_NOSIGNAL标志来禁止写操作触发SIGPIPE信号。在这种情况下,我们应该使用send函数反馈的errno值来判断管道或者socket连接的读端是否已经关闭。

此外,我们也可以利用IO复用系统调用来检测管道和socket连接的读端是否已经关闭。以poll为例,当管道的读端关闭时,写端文件描述符上的POLLHUP事件将被触发;当socket连接被对方关闭时,socket上的POLLRDHUP事件将被触发。

 

内核通知应用程序带外数据的两种方法:

1,IO复用技术,select

2,SIGURG

 

Linux的三种定时方法:

1,socket选项SO_RCVTIMEO和SO_SNDTIMEO

2,SIGALRM信号

3,IO复用系统调用的超时参数

socket的SO_RCVTIMEO和SO_SNDTIMEO这两个选项仅对与数据接收和发送相关的socket系统调用(send,sendmsg,recv,recvmsg,accept,connect) 

根据函数的返回值以及errno来判断超时时间是否已到

由alarm和setitimer函数设置的实时闹钟一旦超时,将触发SIGALRM信号 

 Linux服务器必须处理的三类事件:IO事件,信号和定时器。

Libevent Reactor

 

 http://blog.chinaunix.net/uid-21715511-id-3483439.html

 

 

两种I/O多路复用模式:Reactor和Proactor

一般地,I/O多路复用机制都依赖于一个事件多路分离器(Event Demultiplexer)。分离器对象可将来自事件源的I/O事件分离出来,并分发到对应的read/write事件处理器(Event Handler)。开发人员预先注册需要处理的事件及其事件处理器(或回调函数);事件分离器负责将请求事件传递给事件处理器。两个与事件分离器有关的模式是Reactor和Proactor。Reactor模式采用同步IO,而Proactor采用异步IO。

在Reactor中,事件分离器负责等待文件描述符或socket为读写操作准备就绪,然后将就绪事件传递给对应的处理器,最后由处理器负责完成实际的读写工作。


而在Proactor模式中,处理器--或者兼任处理器的事件分离器,只负责发起异步读写操作。IO操作本身由操作系统来完成。传递给操作系统的参数需要包括用户定义的数据缓冲区地址和数据大小,操作系统才能从中得到写出操作所需数据,或写入从socket读到的数据。事件分离器捕获IO操作完成事件,然后将事件传递给对应处理器。比如,在windows上,处理器发起一个异步IO操作,再由事件分离器等待IOCompletion事件。典型的异步模式实现,都建立在操作系统支持异步API的基础之上,我们将这种实现称为“系统级”异步或“真”异步,因为应用程序完全依赖操作系统执行真正的IO工作。

 

fork():复制进程映像

数据的复制采用的是所谓的写时赋值(copy on write),即只有在任一进程(父进程或子进程)对数据进行了写操作时,复制才会发生(先是缺页中断,然后操作系统给子进程分配内存并复制父进程的数据)

父进程中打开的文件描述符默认在子进程中也是打开的,且文件描述符的引用计数加1.

exec(): 替换进程映像 

有时我们需要在子进程中执行其他程序,即替换当前进程映像,这就需要使用exec()函数

一般情况下,exec函数是不返回的,除非出错。出错时返回-1,并设置errno。如果没出错,则原程序中exec调用之后的代码都不会执行,因为此时原程序已经被exec的参数指定的程序完全替换了(包括代码和数据) 。

exec函数不会关闭原程序所打开的文件描述符,除非该文件描述符被设置了类似SOCK_CLOEXEC的属性。

对于多进程程序而言,父进程一般需要跟踪子进程的退出状态。因此,当子进程结束运行时,内核不会立即释放该进程的进程表项,以满足父进程后续对该子进程退出消息的查询(如果父进程还在运行)。在子进程结束运行之后,父进程读取其退出状态之前,我们称该子进程处于僵尸态。

另外一种使子进程进入僵尸态的情况是:父进程结束或异常终止,而子进程继续运行,此时,子进程的PPID将被操作系统设置为1,即init进程。init进程接管了该子进程,并等待它结束。在父进程退出之后,子进程退出之前,该子进程处于僵尸态。

由此可见,无论哪种情况,如果父进程没有正确地处理子进程的返回信息,子进程都将停留在僵尸态,并占据着内核资源。这是绝对不能容忍的,毕竟内核资源有限。

下面这对函数在父进程中调用,以等待子进程的结束,并获取子进程的返回信息,从而避免了僵尸进程的产生,或者使子进程的僵尸态立即结束:

pid_t wait(int *status);

pid_t waitpid(pid_t pid, int *status, int options);

wait函数将阻塞进程,直到该进程的某个子进程结束运行为止。

wait函数的阻塞特性显然不是服务器程序期望的,而waitpid函数解决了这个问题。 

对于waitpid函数而言,我们最好在某个子进程退出之后再调用它,那么父进程从何得知某个子进程已经退出了呢?这正式SIGCHLD信号的用途,并在信号处理函数中

调用waitpid函数以“彻底结束”一个子进程。

static void handle_child(int sig)

{

  pid_t pid;

  int stat;

  while(( pid = waitpid(-1, &stat, WNOHANG)) > 0)

  {

    /*对结束的子进程进行善后处理*/

  }

}

管道能在父子进程间传递数据,利用的是fork调用之后两个文件描述符(fd[0], fd[1])都保持打开。 一对这样的文件描述符只能保证父子进程间一个方向的数据传输,父子进程必须有一个关闭fd[0], 另外一个关闭fd[1]。如果要实现父子进程间的双向数据传输,就必须使用两个管道。

socket编程接口提供了一个创建全双工管道的系统调用:socketpair

管道只能用于有关联的两个进程(父子进程)间的通信。FIFO命名管道则能用于无关联进程之间的通信。

 

semget, semop, semctl都被设计为操作一组信号量,即信号量,而不是单个信号量 

信号量,共享内存,消息队列这三种System V IPC进程间通信方式都使用一个全局唯一的key来描述一个共享资源,当程序调用semget,shmget或者msgget时,就创建了这些共享资源的一个实例,可通过ipcs命令查看。 

通过ipcrm命令来删除遗留在系统中的共享资源。

 

由于fork调用之后,父进程中打开的文件描述符在子进程中仍然保持打开,所以文件描述符可以很方便地从父进程传递到子进程。需要注意的是,传递一个文件描述符并不是传递一个文件描述符的值,

而是要在接收进程中创建一个新的文件描述符,并且该文件描述符和发送进程中被传递的文件描述符指向内核中相同的文件表项。

那么如何把子进程中打开的文件描述符传递给父进程?或者,在两个不相干的进程间传递文件描述符呢?在Linux下,可以利用UNIX域socket在进程间传递特殊的辅助数据(也被叫做控制消息,他们并不是socket的payload),以实现文件描述符的传递,然后接收进程就通过该文件描述符来操作文件。

http://man7.org/linux/man-pages/man3/cmsg.3.html 

These macros are used to create and access control messages (also
       called ancillary data) that are not a part of the socket payload.
       This control information may include the interface the packet was
       received on, various rarely used header fields, an extended error
       description, a set of file descriptors or UNIX credentials.  For
       instance, control messages can be used to send additional header
       fields such as IP options.  Ancillary data is sent by calling
       sendmsg(2) and received by calling recvmsg(2).  See their manual
       pages for more information.

 Ancillary data is a sequence of struct cmsghdr structures with
       appended data.  This sequence should be accessed using only the
       macros described in this manual page and never directly.  See the
       specific protocol man pages for the available control message types.
       The maximum ancillary buffer size allowed per socket can be set using
       /proc/sys/net/core/optmem_max; see socket(7).

 

POSIX线程同步方式:POSIX信号量,互斥锁,条件变量 

线程函数在结束时最好调用以下函数,以确保安全,干净得退出:

void pthread_exit(void *retval);

一个进程中的所有线程都可以调用pthread_join函数来回收其他线程(前提是目标线程是可回收的), 即等待其他线程结束,这类似于回收进程的wait和waitpid系统调用。

int pthread_join(pthread_t thread, void **retval);

该函数会一直阻塞,直到被回收的线程结束为止。

int pthread_cancel(pthread_t thread);

int pthread_setcancelstate(int state, int *oldstate);
int pthread_setcanceltype(int type, int *oldtype);

pthread_detach(), PTHREAD_CREATE_DETACH “脱离线程” 在退出时将自行释放其占用的系统资源

 

线程同步机制:信号量,互斥量和条件变量

信号量分SYSTEM V信号量和POSIX信号量

 

int pthread_atfork(void (*prepare)(void), void (*parent)(void),void (*child)(void));

每个线程都可以独立地设置信号掩码

进程:sigprocmask()     线程:pthread_sigmask()

由于进程中的所有线程共享该进程的信号,所以线程库将根据线程掩码决定把信号发送给哪个具体的线程。因此,如果我们在每个子线程中都单独设置信号掩码,就很容易导致逻辑错误。此外,所有线程共享信号处理函数。

也就是说,当我们在一个线程中设置了某个信号的信号处理函数后,它将覆盖其他线程为同一个信号设置的信号处理函数。这两点都说明,我们应该定义一个专门的线程来处理所有的信号。这可以通过如下两个步骤来实现:

1.在主线程创建出其他子线程之前就调用pthread_sigmask来设置好信号掩码,所有新创建的子线程都将自动继承这个信号掩码。这样做之后,实际上所有线程都不会响应被屏蔽的信号了。

2.在某个线程(专门处理信号的线程)中调用如下函数来等待信号并处理之:

 

int sigwait(const sigset_t *set, int *sig);

 

 

int pthread_kill(pthread_t thread, int sig);   明确地将一个信号发送给指定的线程

动态创建的子进程是当前进程的完整映像,当前进程必须谨慎地管理其分配的文件描述符和堆内存等系统资源,否则子进程可能复制这些资源。 

 

/proc/sys/fs/file-max

/proc/sys/fs/epoll/max_user_watches

/proc/sys/net/ipv4/tcp_max_syn_backlog 

/proc/sys/net/ipv4/tcp_rmem 

/proc/sys/net/ipv4/tcp_syncookies    指定是否打开TCP同步标签(syncookie)。同步标签通过启动cookie来防止一个监听socket因不停地重复接收来自同一个地址的连接请求(同步报文段SYN),而导致listen监听队列溢出(所谓的SYN风暴)。

 

 

sendfile()

socketpair()

sockatmark()