加载中…
正文 字体大小:

学习笔记--高级I/O与高性能服务器编程基本框架

(2014-10-26 16:22:11)
标签:

it

上个星期涉足了一下高性能服务器编程基本框架,在这里做一个小小的笔记。

服务器编程的基本框架为:


学习笔记--高级I/O与高性能服务器编程基本框架

  

它主要分为I/O处理单元逻辑单元网络存储单元请求队列。其中网络存储单元是可选的。从图中可以看出请求队列是两个单元之间的通信方式。因为一般来说,服务器方面的程序属于I/O密集型的程序,所以对于I/O这一方面的处理,就有多种多样的模型了。


首先就是非常经典的人们一般会用到的阻塞I/O阻塞I/O就是当读写操作没有完成时,函数就不会返回,进程一直阻塞在那里。例如,在某些文件类型的数据并不存在时,读操作可能会使调用者永远阻塞,又或者数据不能被相同的文件类型立即接受,写操作可能会使调用者永远阻塞。从服务器编程的高性能的角度来看,这样做会导致服务端的性能下降。


然后人们又提出了非阻塞I/O非阻塞I/O就是函数立即返回,I/O没有就绪就返回错误。那么I/O没有就绪和函数调用出错情况一样都会返回-1。此时,就需要通过errno值来判断此次出错是I/O没有就绪还是函数返回出错。


通常有两种方法给一个给定的描述符指定非阻塞I/O方法。

1.如果调用open获得描述符,则可指定O_NONBLOCK标志

2.对一个已经打开的一个描述符,则可调用fcntl,由该函数打开O_NONBLOCK文件状态标志。


因为非阻塞I/O就是函数立即返回,所以有一种很经典的方法就是让请求进程主动轮询不断地发送I/O请求直到返回正确值。


什么是轮询?比如说,假设此时有两个输入描述符,并且都设为非阻塞的。对第一个描述符发一个read。如果该输入上有数据,则读数据并处理它,如果没有数据可读,则该调用立即返回。然后对第二个描述符做同样的处理。在此之后,等待一定的时间,然后再尝试从第一个描述符读。


 

采用进程轮询的方式会使CPU利用率下降很多,本来我们是想通过非阻塞I/O来提高服务器端的性能,但通过这种方式的话,并没有想象中提高多少,所以一般来说我们要避免使用轮询这种方法。所以一般非阻塞I/O通常回合其他I/O通知机制一起使用,比如I/O复用和SIGIO信号


I/O复用技术:首先要使用这种技术,要先构造一张我们感兴趣的描述符的列表,然后调用一个函数,直到这些描述符中的一个已准备好进行I/O时该函数才返回。多路复用函数本身是可以阻塞的,但是它们的高级之处在于它们可以同时等待多个描述符,而这些描述符其中的任意一个就绪,就可以返回。其实是应用程序通过I/O复用函数向内核注册一组事件,内核通过I/O复用函数把其中就绪的事件通知给应用程序,由内核来负责本来是请求进程该做的轮询操作。

 

例如:select()函数

#include  \

int select(int maxfdp1, fd_set *restrict readfds, fd_set *restrict writefds, fd_set *restrict exceptfds, struct timeval *restrict tvpr);

返回值:准备就绪的描述符数目;若超时,返回0;若出错,返回-1


首先,select的参数告诉内核:

1.我们关心的描述符;

2.对于每个描述符我们所关心的条件(是否想从一个给定的描述符读、是否想从一个给定的描述符写,是否关心一个给定描述符的异常条件)

3.愿意等待多长时间(可以永远等待、等待一个固定的时间或者根本不等待)

4.已准备好的描述符的总数量

5.对于读、写或异常这三个条件中的每一个,哪些描述符已准备好。


1.maxfdp1是“最大文件描述符编号值加1

2.readfds, writefds, exceptfds是指向描述符集的指针,这三个描述符集说明了我们关心的可读、可写或处于异常的描述符集合,如果我们某参数上设置为NULL,那么表示我们并不关心该类描述符集。


这三个描述集都是fd_set类型的,唯一可以进行的处理是:分配一个这种类型的变量,将这种类型的一个变量赋值给同类型的另一个变量,或对这种类型的变量使用下列4个函数中的一个:

#include  \

int  FD_ISSET(int fd, fd_set  *fdset); //测试描述符集中的一个指定位是否已打开

void  FD_CLR(int fd, fd_set  *fdset); //清除一位

void  FD_SET(int fd, fd_set *fdset);  //开启描述符集中的一位

void   FD_ZERO(fd_set  *fdset);  //将所有位设置为0


3.我们通过设置最后一个参数告诉内核是否需要等待:

1.tvpr == NULL 永远等待。如果捕捉到一个信号则中断无限期等待。当所指定的描述符中的一个已准备好或捕捉到一个信号则返回。如果捕捉到一个信号,则select返回-1,并且errno设置为EINTR

2.tvpr->tv_sec == && tvpr->tv_usec =根本不等待,测试所有指定的描述符并立即返回。这是轮循系统找到多个描述符状态而不阻塞select函数的方法。

3.tvpr->tv_sec != || tvpr->tv_usec 等待指定的秒数和微秒数。当指定的描述符之一已准备好,或当指定的时间值超过时立即返回。如果在超时到期时还没有一个描述符准备好,则返回值是0。这种等待可能被捕捉到的信号中断。


需要说明的一点是描述符阻塞与否并不会影响select是否阻塞。比如说,如果希望读一个非阻塞的文件描述符,并且以超时值为3秒调用select,则select最多阻塞3秒。

 

其次需要说明的是什么是描述符准备好

1.若对读集中的一个描述符进行的read操作不会阻塞,则认为此描述符是准备好的

2.若对写集中的一个描述符进行的write操作不会阻塞,则认为描述符是准备好的

3.若对异常条件集中的一个描述符有一个未决异常条件,则认为此描述符是准备好的。

4.对于读、写和异常条件,普通文件的文件描述符总是返回准备好的。


还有一个函数poll

#include  \<</b>poll.h\>

int poll (struct pollfd fdarry[], nfds_t nfds, int timeout);

struct pollfd{

int fd;

short events;       //等待的需要测试事件,设置为一个或几个宏值

short revents;    //返回时,revents成员由内核设置,用于说明每个描述符发生了哪些事件

}


poll不会更改events成员。

I/O模型中,阻塞I/OI/O复用与信号驱动I/O属于同步I/O所谓同步I/O就是在I/O事件发生后,才会有I/O读写操作。

有同步就会有异步,异步I/O就是用户可以直接对I/O执行读写操作,这些操作告诉内核用户读写缓冲区的位值,以及I/O操作完成之后内核通知应用程序的方式,真正的读写已经由内核接管了。


使用POSIX异步I/O接口会产生一些麻烦:

1.每个异步操作有3处可能产生错误的地方:一处在操作提交的部分,一处在操作本身的结果,还有一处在用于决定异步操作状态的函数中。

2.POSIX异步I/O接口的传统方法相比,它们本身涉及大量的额外设置和处理流程

3.从错误中恢复可能会比较困难,


在异步I/O接口中,使用AIO控制块来描述I/O操作。aiocb结构体定义了AIO控制块。

strcut aiocb{

int aio_fileds;

off_t aio_offset;

volatile void *aio_buf;

size_t aio_nbytes;

int aio_reqprio;

struct  sigevent aio_sigevent;

int aio_lio_opcode;

...

};


1.aio_fieds:表示被打开用来读或写的文件描述符

2.aio_offset:表示读、写操作开始的文件偏移量。异步I/O接口并不影响由操作系统维护的文件偏移量。如果使用异步I/O接口向一个以追加模式打开的文件写入数据,AIO控制块中的aio_offset将会被忽略。

3.aio_buf:对于读操作,数据会复制到缓冲区aio_buf中;对于写操作,数据会从这个缓冲区复制得到。

4.aio_nbytes表示读、写的字节数

5.aio_reqprio异步I/O请求提示顺序

6.aio_sigeventsI/O事件完成后,如何通知应用程序。


struct sigevent{

int sigev_notify;

int sigev_signo;

union sigval sigev_value;

void (*sigev_notify_function) (union sigval);

pthread_attr_t *sigev_notify_attributes;

};


sigev_notify:通知的类型,可以为一下三种:

SIGEV_NONE:不通知进程

SIGEV_SIGNAL:产生由sigev_signo字段指定的信号。如果应用程序已经选择捕捉信号,且在建立信号处理 程序的时候指定了SA_SIGINFO标志,那么该信号将被入对。信号处理程序会传送给一个siginfo结构,该结构的si_value字段被设置为sigev_value

SIGEV_THREAD当异步I/O请求完成时,由sigev_notify_function字段指定的函数被调用。sigev_value字段被传入作为它的唯一参数。除非sigev_notify_attributes字段被设定为pthread属性结构的地址,且该结构指定了一个另外的线程属性,否则该函数将在分离状态下的一个单独的线程中执行。

 

7.aio_lio_opcode只能用于基于列表的异步


异步I/O的主要API

#include \

int aio_read(struct aiocb *aiocb);

int aio_write(struct aiocb *aiocb);

int aio_fsync(int op, struct aiocb *aiocb);

int  aio_error(const struct aiocb *aiocb);

ssize_t aio_return(const struct aiocb *aiocb);

int aio_suspend(const struct aiocb *const list[], int rent, const struct timespec *timeout);

int aio_cancel(int fd, struct aiocb *aiocb);

int  lio_listio(int mode, struct aiocb *restrict const list[restrict], int nent, struct sigevent *restrict sigev);


在高级I/O中,有一个非常重要的概念——存储映射I/O

储映射I/O能将一个磁盘文件映射到存储空间中的一个缓冲区上。也就是说,当从缓冲区中取数据时,就相当于读文件中的相应字节。当数据存入缓冲区时。相应字节就自动写入文件。

 

首先我们学要告诉内核一个给定的文件映射到一个存储区域中。

#include  \

void *mmap(void  *addr,  size_t  len,  int  prot, int  flag, inf  fd,  off_t  off);

函数返回值是映射区的起始地址;若出错,返回MAP_FAILED


1.addr:指定映射存储区的起始地址。通常设置为0,表示由系统选择该映射区的起始地址。

2.len:指定映射区长度

3.prot:指定了映射存储区的保护要求。

学习笔记--高级I/O与高性能服务器编程基本框架

4.flag影响映射存储区的多种属性。
学习笔记--高级I/O与高性能服务器编程基本框架
5.fd:指定要被映射文件的描述符。在文件映射到地址空间之前,必须先打开文件。

6.off:要映射字节在文件中的起始偏移量。而映射文件的起始偏移量受系统虚拟存储页长的限制。例如,如果文件长12字节,系统页长512字节,则系统通常提供512字节的映射区,其中后500字节会被设置为0.可以修改后面的这500字节,但任何变动都不会在文件中反映出来。

其中与映射区相关的信号有SIGSEGVSIGBUS

SIGSEGV通常用于指示进程试图访问对它不可用的存储区。比如说,如果映射存储区被mmap指定成了只读的,那么进程试图将数据存入这个映射存储区的时候就会产生此信号。

SIGBUS如果映射区的某个部分在访问时不存在,就会产生此信号。

最后子进程能通过fork继承存储映射区,但是新程序则不能通过exec继承存储映射区。当进程终止时,会自动


还有一些其他的API:

#include 

//更改一个现有映射的权限

int mprotect(void  *addr, size_t  len, int  prot);

//同步到磁盘的文件中。就是如果共享映射中的页已修改,那么就可以调用msync将该页冲洗到映射的文件中

int msync(void *addr, size_t len, int  flags);

//解除存储映射区的映射。

int munmap(void *addr, size_t  len);


在服务器编程这块,有两个特别典型的事件处理模型:ReactorProactot


Reacot模式主线程(I/O处理单元)只负责监听文件描述上是否有事件发生,有的话立即将该事件通知工作线程(逻辑单元)。工作线程负责:读写数据,接受新的连接,以及处理客户请求。


同步模型 epoll_wait为例

学习笔记--高级I/O与高性能服务器编程基本框架

首先主线程往epoll内核事件表中注册socket上的读就绪事件。然后这线程调用epoll_wait等待socket上有数据可读。当数据socket上有数据可读时,epoll_wait通知主线程。主线程将socket可读事件放入请求队列。睡眠在请求队列上的某个工作线程被唤醒,它从socket读取数据,并处理客户请求,然后往epoll内核事件表中注册socket上的写就绪事件。接着主线程调用epoll_wait等待socket可写。当socket可写时,epoll_wait通知主线程。主线程将socket可写事件放入请求队列。睡眠在请求队列上的某个工作线程被唤醒,它往socket上写入服务器处理客户请求的结果。


Proactor模式:

所有的I/O操作都交给主线程和内核来处理。工作线程负责业务逻辑。


异步I/O模型,epoll_wait为例

学习笔记--高级I/O与高性能服务器编程基本框架


主线程调用aio_read函数向内核注册socket上的读完成事件,并告诉内核用户读数缓冲区的位值,以及读操作完成时如何通知应用程序。此时主线程继续处理其他逻辑。


socket上的数据被读入用户缓冲区后,内核将向应用程序发送一个信号,以通知应用程序数据已经可用。接着应用程序预先定义好的信号处理函数选择一个工作线程来处理客户请求。工作线程处理完客户请求之后,调用aio_write函数向内核注册socket上的写完成事件,并告诉内核用户写缓冲区的位置,以及写操作完成时如何通知应用程序。同样此时主线程处理其他逻辑


最后当用户缓冲区的数据被写入socket之后,内核将向应用程序发送一个信号,以通知应用程序数据已经发送完毕。应用程序预先定义好的信号处理函数选择一个工作线程来作善后处理,比如决定是否关闭socket


提高服务器性能还有一些其他建议比如说可以从几个方面考虑:

1.池就是一组资源的集合,这组资源在服务器启动之初就被完全创建并初始化。当服务器进入正式运行阶段,即开始处理客户请求的时候,如果它需要相关的资源就可以直接从池中获取,无需动态分配。


2.数据复制:避免不必要的数据复制,尤其是当数据复制发生在用户代码和内核之间的时候。


3.上下文切换和锁:进程切换或线程切换会占用CPU大量时间。而锁本身是为了解决共享资源问题,本身不仅不处理任何业务逻辑,而且学要访问内核资源。所以也要尽量避免使用锁。


学习资料与参考书籍《APUE》、《Linux高性能服务器编程》、网上好多博客及各种百科。

 

 


 

 

 

 

 

0

阅读 评论 收藏 转载 喜欢 打印举报
已投稿到:
  • 评论加载中,请稍候...
发评论

    发评论

    以上网友发言只代表其个人观点,不代表新浪网的观点或立场。

      

    新浪BLOG意见反馈留言板 不良信息反馈 电话:4006900000 提示音后按1键(按当地市话标准计费) 欢迎批评指正

    新浪简介 | About Sina | 广告服务 | 联系我们 | 招聘信息 | 网站律师 | SINA English | 会员注册 | 产品答疑

    新浪公司 版权所有