I/O 多路复用之 select
I/O多路复用的概念,我们已经再耳熟能详不过了,比如接触很多的nginx、Redis,均利用了I/O多路复用。那么I/O多路复用到底是什么?内核提供了多种方法,比如epoll、poll、select等。
举个栗子
一个客户端,要同时读标准输入过来的消息,和服务远端传过来的消息,然后将其输出到标准输出。怎么实现?
如果这边等着标准输入,那么socket那边传过来消息就没法及时读到,同样等着socket过来,标准输入又顾及不到。因为这些I/O默认都是阻塞的。
基于进程或者线程的并发编程是可以实现的,缺点是fork进程的资源消耗或是多线程的复杂性等。多进程后者多线程模型就是服务器早期的实现。
那有没有单进程单线程下更优的解决方案呢? 这就是I/O多路复用了。
最早开始出现的支持同步I/O复用的就是select了,随着发展有性能更好的epoll出现。这里讲一下select的实现。
select
Mac上man一下命令 man select
或者查看 手册
概述
select() 它能允许程序同时监控多个文件描述符(可读、可写、异常),等待其中一个或多个描述符准备好了IO操作(准备好了,是指数据已经在内核缓冲区了,再读出来还需要从内核缓冲区拷贝到用户空间)。
- select的常见用法,用来操作这些描述符集合:
#include <sys/select.h>
# 清除掉该描述符
void FD_CLR(fd, fd_set *fdset);
# 拷贝描述符集合
void FD_COPY(fd_set *fdset_orig, fd_set *fdset_copy);
# 常用 判断该描述符是不是准备好了。Linux返回0或1。Mac表现不一样,返回位的十进制大小,比如fd=3在则返回8
int FD_ISSET(fd, fd_set *fdset);
# 常用 把关注的描述符放到集合里
void FD_SET(fd, fd_set *fdset);
# 清空描述符集合
void FD_ZERO(fd_set *fdset);
- select()函数介绍:
int select(int nfds, fd_set *restrict readfds, fd_set *restrict writefds, fd_set *restrict errorfds, struct timeval *restrict timeout);
- nfds参数,是要check的文件描述符数,从0到nfds-1。注意下,比如要监控俩描述符1和17,那么nfds参数需要传入17+1,而不是2。Linux默认最大是1024, select()有这个限制,也可以改大这个常量。epoll没有这个限制。
- fd_set *restrict readfds,表示关注的可读描述符集合。
- fd_set *restrict writefds,表示关注的可写描述符集合。
- fd_set *restrict errorfds,表示异常描述符集合。
- struct timeval *restrict timeout,是超时时间。如果是非空指针,表示阻塞的超时时长;如果是空指针会一直阻塞着,直到有准备好的描述符。这个参数在不同的系统平台表现会不一样,比如Mac上select不会修改它,但是Linux上会修改它。
- int select,返回值表示这些描述符集合中准备好的个数。-1表示发生错误。超时会返回0。
fd_set
参数列表中,一个常见的fd_set是什么?字面上是文件描述符集合。它是个结构体,内部实际是个long int类型数组,利用的位向量表示的。FD_SETSIZE=1024个描述符,一个long是8个字节=64bit,一个bit表示一个描述符,需要1024/64=16个数组元素。
更直白的说,fd/64 = m…n(m是商数,n是余数),m就是数组下标,n就是一个long int的第n bit位是1.
结合源码,稍微展开下:
// 数组元素是一个long int类型的
typedef long int __fd_mask;
// 一个long int8字节,NFDBITS=8*8=64bit
#define __NFDBITS (8 * (int) sizeof (__fd_mask))
// 有的平台把它当32
# define NFDBITS __NFDBITS
// 忽略了一些兼容问题
typedef struct {
__fd_mask __fds_bits[__FD_SETSIZE / __NFDBITS];
# define __FDS_BITS(set) ((set)->__fds_bits)
} fd_set;
fd_set也可以简单看成是:
typedef struct {
long fds_bits[1024/64];
} fd_set;
一个select()的例子:
#include <stdio.h>
#include <stdlib.h>
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>
#include <arpa/inet.h>
int main(void) {
fd_set rfds;
struct timeval tv;
int retval;
/* 查看常量值 FD_SETSIZE NFDBITS */
printf("FD_SETSIZE=%d,NFDBITS=%lu, FD_SETSIZE / NFDBITS=%lu\n", FD_SETSIZE, NFDBITS, FD_SETSIZE / NFDBITS);
/* 下面是连接服务端代码 */
// 套接字文件描述符
int clientSocket;
// 描述服务器的socket
struct sockaddr_in serverAddr;
char recvbuf[200];
int dataLen;
clientSocket = socket(AF_INET, SOCK_STREAM, 0);
// 客户端打开的网络socket描述符= 3 (0 1 2是标准输入输出错误,进程下文件描述符从小到大)
printf("clientSocket: %d\n", clientSocket);
serverAddr.sin_family = AF_INET;
serverAddr.sin_port = htons(8090);
serverAddr.sin_addr.s_addr = inet_addr("127.0.0.1");
// connect服务端
connect(clientSocket, (struct sockaddr *) &serverAddr, sizeof(serverAddr));
/* 连接服务端结束 */
/* 监视标准输入描述符,看什么时候有输入进来*/
FD_ZERO(&rfds);// 清空描述符集合
FD_SET(0, &rfds);// 把标准输入放进去
FD_SET(clientSocket, &rfds);//
/* 等待5s */
tv.tv_sec = 5;
tv.tv_usec = 0;
// select 调用
retval = select(clientSocket + 1, &rfds, NULL, NULL, &tv);
/* Mac系统下运行tv不受改变;Linux下运行改变了time参数,tv.tv_sec是0 */
printf("time %ld\n", tv.tv_sec);
if (retval == -1){
perror("select()");
} else if (retval){
// 标准输入准备就绪
if(FD_ISSET(0, &rfds)){
printf("Std Input is available now. retval=%d\n", retval);
}
// 网络socket准备就绪
if(FD_ISSET(clientSocket, &rfds)){
recvbuf[0] = '\0';
dataLen = recv(clientSocket, recvbuf, 200, 0);
printf("dataLen: %d, recvbuf: %s\n", dataLen, recvbuf);
close(clientSocket);
}
} else {
printf("No data within five seconds.\n");
}
exit(EXIT_SUCCESS);
}
服务端代码(两秒后,服务端发送一句话给客户端),用GO来简单实现下,重点不是它
package main
import (
"time"
"net"
)
func main(){
listen, _ := net.Listen("tcp", "localhost:8090")
for{
conn, _ := listen.Accept()
go handlefunc(conn)
}
}
/**
间隔两秒后,发一条消息给客户端
忽略错误
*/
func handlefunc(conn net.Conn){
defer conn.Close()
time.Sleep(2*time.Second)
str := "send a string to client " + conn.RemoteAddr().String()
conn.Write([]byte(str))
}
程序中把fd=0 clientSocket描述符放进集合,select运行后,描述符集合fd_set会被更新成只有准备好的描述符。之前放进去的描述符被干掉了,如果程序还想继续监听这些描述符,那需要重新放回去。
通常调用select()前把fd集合放到一个数组下,select()执行后,循环来判断某个fd是否就绪,同时也能知道哪些fd这次没有就绪方便下次再放进去。
缺点
那么select有哪些缺点呢?
-
fd数量限制,最多1024,参数可以修改
-
fd数量增多时性能表现差,select执行如下:
- 所有待关心fd集合从用户态拷贝到内核
- 内核挨个遍历fd,检测是否可读可写,修改有读写事件的集合
- 内核态拷贝回用户态,用户态再挨个遍历是否就绪
-
API易用性差,每次select()时,都要重新把关心的fd set进去。
以上就是对I/O复用的select认识,后边再开一篇epoll,其在fd较多时性能更优秀。