大纲
非阻塞I/O
阻塞I/O就是发出IO请求后内核会查看数据是否就绪,如果没就会等待数据就绪,此时线程就处于阻塞状态。而非阻塞IO是发出请求后并不需要等待,而是马上得到一个结果。我们之前用的都是阻塞I/O,但是在某些实现上用阻塞I/O不太妥当。举例如下:假设设备A和设备B要进行数据交换
这里只是由一个线程或进程来完成。如果A没有数据,刚开始就阻塞,后面的谁都动不了
可以分成两个任务来做:
这里由两个线程或者进程实现,一个负责读A写B,一个负责读B写A。
上面两个做法,第一个如果用阻塞IO实现可以直接宣告死亡,第二个用阻塞实现还稍微能动一动。但如果都改为非阻塞IO,第一个都可以正常实现:读A,没读到就读B,也没读到就继续读A…
补充:有限状态机编程思想
利用有限状态机模型编程
利用有限状态机思想编写上面交换ab设备的代码,先画出状态图
不管是读a写b还是读b写a都符合上面的状态图。其中EAGAIN是非阻塞IO中,如果你连续做read操作而没有数据可读,此时程序不会阻塞起来等待数据准备就绪返回,read函数会返回一个错误EAGAIN,提示你的应用程序现在没有数据可读请稍后再试。或者fork函数因为没有足够资源而不能生成子进程也会返回这个EAGAIN。
首先main函数的内容是模拟用户的操作,打开两个文件,然后交换两个文件的内容。还有有限状态机,这里每个状态点都用结构体表示,因为在读和写状态要进行IO操作,所以需要读写源文件和目标文件
//一共四个状态,用枚举表示
enum
{
STATE_R = 1,
STATE_W,
STATE_Ex,
STATE_T
};
struct fsm_st
{
int state;
int sfd;
int dfd;
int len;
int pos;
char buf[BUFSIZE];
char *errstr;
};
int main()
{
int fd1, fd2;
//这里假装以非阻塞形式打开,看看有没有影响
if ((fd1 = open(TTY1, O_RDWR)) < 0) { }
if ((fd2 = open(TTY2, O_RDWR | O_NONBLOCK)) < 0) { }
relay(fd1, fd2);
close(fd1);
close(fd2);
exit(0);
}
relay函数负责交换
static void relay(int fd1, int fd2)
{
//开头的代码用于确保fd1和fd2都是非阻塞IO,所以假装非阻塞也没事
//不管原来怎么样,这里都加上了非阻塞属性,同时保存了原来的属性
int fd1_save = fcntl(fd1, F_GETFL);
fcntl(fd1, F_SETFL, fd1_save | O_NONBLOCK);
int fd2_save = fcntl(fd2, F_GETFL);
fcntl(fd2, F_SETFL, fd2_save | O_NONBLOCK);
//这里用两个状态机,一个读左写右边,一个读右写左
//初始状态都是读
struct fsm_st fsm12, fsm21;
fsm12.state = STATE_R;
fsm12.sfd = fd1;
fsm12.dfd = fd2;
fsm21.state = STATE_R;
fsm21.sfd = fd2;
fsm21.dfd = fd1;
//只要不是终止状态就一直运行状态机
while (fsm12.state != STATE_T || fsm21.state != STATE_T)
{
fsm_driver(&fsm12);
fsm_driver(&fsm21);
}
//结束后还原文件之前的属性
fcntl(fd1, F_SETFL, fd1_save);
fcntl(fd2, F_SETFL, fd2_save);
}
fsm_driver相当于状态间的线,从一个状态切换到另一个状态所执行的动作
static void fsm_driver(struct fsm_st *fsm)
{
int ret;
switch (fsm->state)
{
//状态图中r有四条线指向其他状态
case STATE_R:
fsm->len = read(fsm->sfd, fsm->buf, BUFSIZE);
if (fsm->len == 0)
//第一条线:如果读完则切换到T状态
fsm->state = STATE_T;
else if (fsm->len < 0)
{
if (errno == EAGAIN)
//第二条线:如果没数据就继续读
fsm->state = STATE_R;
else
{
//第三条线:如果出错则退出,切换到Ex状态
fsm->errstr = "read()";
fsm->state = STATE_Ex;
}
}
else
{
//第四条线:读取成功,切换到写状态
fsm->pos = 0;
fsm->state = STATE_W;
}
break;
case STATE_W:
//w也有四条线指向其他状态
//注意从pos位置续写
ret = write(fsm->dfd, fsm->buf + fsm->pos, fsm->len);
if (ret < 0)
{
if (errno == EAGAIN)
//第一条线:阻塞则接着读
fsm->state = STATE_W;
else
{
//第二条线:出错则切换到Ex状态
fsm->errstr = "write()";
fsm->state = STATE_Ex;
}
}
else
{
fsm->pos += ret;
fsm->len -= ret;
if (fsm->len == 0)
//第三条线:写完了,再切换回r状态
fsm->state = STATE_R;
else
//第四条线:还没写完,继续写
fsm->state = STATE_W;
}
break;
case STATE_Ex:
perror(fsm->errstr);
fsm->state = STATE_T;
break;
case STATE_T:
break;
default:
abort();
break;
}
}
IO多路转接
数据在通信过程中,分为两部分:
- 等待数据到达内核。
- 将数据从内核拷贝到用户区。
然而在实际的应用中,等待的时间往往比拷贝的时间多得多,所以我们要想提高效率,就必然要减少等的比重。IO多路转接就是解决这个问题的:一次监视多个文件描述符的状态变化。由于一次等待多个文件描述符,在单个时间内,就绪事件发生的概率就越大,进而等待的比重就会越小。
IO多路转接的实现方式有select、poll和epoll三种。
select
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);
nfds表示需要监视的最大的文件描述符值+1。用于限定操作系统遍历的区间,它只关心需要监视的部分,其他区间不会去遍历,这样减少了开销。
后面三个参数类型都是fd_set的指针,而fd_set其实是个整数数组,或者说是“位图”。使用位图中对应的位来表示要监视的文件描述符。文件描述符是数组的下标,也就是从0开始的整数。所以对于位图的每一个bit位表示的是一个文件描述符的状态,1表示关心该文件描述符上的事件;0表示不关心。
而具体是监视文件描述符上的读事件、写事件还是异常事件,就由中间这三个参数决定。如果想监视fd的读,就放入readfds集合中,想监视fd的写,就放入writefds中。这里可以看出,select是以关心的事件为单位来组织文件描述符的
这三个参数都是输入输出型参数,作为输入表示用户告诉内核需要关心文件描述符的哪些事件;作为输出表示内核告诉用户哪些文件描述符的事件已经准备就绪了。
通过下面这四个函数用来操作这些位图:
void FD_CLR(int fd,fd_set *set); //删除set中的fd
int FD_ISSET(int fd,fd_set *set); //判断fd是否在set中
void FD_SET(int fd,fd_set *set); //把要关心的fd放入set中
void FD_ZERO(fd_set *set); //清空set
timeout是超时设置,如果设置为NULL表示阻塞等待,阻塞直到某个关心的事件发生。设置为0表示非阻塞等待,不管有没有事件发生都会立即返回。设置时间则表示select只会阻塞等待一定的时间,在时间内关心的事件发生则正常返回,否则超时返回。
返回值大于0表示执行成功,返回的是文件描述符已改变的个数。等于0表示在规定时间内没有事件发生,超出了timeout的事件。等于-1表示出错,设置errno
select改写数据交换代码
像我们之前用有限状态机思想写的数据交换中下面这段代码就会进行忙等。
while (fsm12.state != STATE_T || fsm21.state != STATE_T)
{
fsm_driver(&fsm12);
fsm_driver(&fsm21);
}
盲等体现在返回错误EAGAIN的时候,如果读/写一直出现没数据读/数据已经写满的情况时,就会一直循环判断是否可读/可写
可以利用select进行监听改进
fd_set rset, wset;
while (fsm12.state != STATE_T || fsm21.state != STATE_T)
{
//布置监视任务,初始化要监视的集合
FD_ZERO(&rset);
FD_ZERO(&wset);
if (fsm12.state == STATE_R)
FD_SET(fsm12.sfd, &rset); //监视fsm12.sfd有没有被读
if (fsm12.state == STATE_W)
FD_SET(fsm12.dfd, &wset); //监视fsm12.dfd有没有被写
if (fsm21.state == STATE_R)
FD_SET(fsm21.sfd, &rset); //监视fsm21.sfd有没有被读
if (fsm21.state == STATE_W)
FD_SET(fsm21.dfd, &wset); 监视fsm12.dfd有没有被写
//监视
if (select(max(fd1, fd2) + 1, &rset, &wset, NULL, NULL) < 0)
{
if (errno == EINTR) //被信号打断则继续
continue
perror("select()");
exit(1);
}
//查看监视结果
//如果fsm12.sfd已读或者fsm12.dfd已写,则驱动状态机改变状态
if (FD_ISSET(fsm12.sfd, &rset) || FD_ISSET(fsm12.dfd, &wset))
fsm_driver(&fsm12);
//同理
if (FD_ISSET(fsm21.sfd, &rset) || FD_ISSET(fsm21.dfd, &wset))
fsm_driver(&fsm21);
}
刚开始将想要监视读的两个源文件的fd放入rset中,想要监视写的目标文件的fd放入wset中。之后select对这两个集合进行监视,监视完后集合中的内容会变为发生了变化的文件描述符的集合,使用FD_ISSET函数
select存在的缺陷
- 监视集合和监视结果存放在同一个空间。也就是中间三个输入输出类型参数的弊端,如果被信号打断,rset和wset的内容都已经不再是原来的刚初始化时候的内容了,所以必须continue重新初始化。这也是判断select结果小于0为什么不用while的原因
- 上面代码中监视被信号打断会continue,进而重新初始化监视集合,可如果要监视的文件描述符有几百上千个,那代码会非常繁杂,运行代价也很大。
- 第一个参数nfds的类型是int,如果文件描述符最大值再加1超过了int的上限,这里就会发生溢出
- 监听的事件非常单一。除了读和写之外其余全划分到了异常。可是异常分类太多了,就比如EAGAIN就不是一个真正的异常
poll
poll和select本质没有多大差别,但是select是以事件为单位组织文件描述符,而poll相反,是以文件描述符为单位来组织事件。
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
第一个参数其实是一个结构体数组,第二个参数就是数组的长度。第三个参数也是超时设置,不过是以毫秒为单位(1000代表一秒),写0表示非阻塞等待,-1表示阻塞等待。结构体内容如下
struct pollfd {
int fd; //想监视的fd
short events; //指定检测的事件
short revents; //操作结果
};
注意events由用户对代表各种事件的标志(POLLIN 数据可读、POLLOUT 数据可写)进行逻辑或运算设置,告诉内核我们关心什么,而revents是返回时内核设置的,以说明对该描述符发生了什么事件。
返回值大于0表示执行成功,返回的是文件描述符已改变的个数。等于-1表示出错,设置errno
poll改写数据交换代码
struct pollfd pfd[2];
pfd[0].fd = fd1;
pfd[1].fd = fd2;
while (fsm12.state != STATE_T || fsm21.state != STATE_T)
{
//布置监视任务
pfd[0].events = 0;
//fsm12可读说明1可读,在可读状态下监测有没有被读成功
if (fsm12.state == STATE_R)
pfd[0].events |= POLLIN;
//fsm21可写说明1可写,在可写状态下监测有没有被写成功
if (fsm21.state == STATE_W)
pfd[0].events |= POLLOUT;
pfd[1].events = 0;
//fsm12可写说明2可写,在可写状态下监测有没有被写成功
if (fsm12.state == STATE_W)
pfd[1].events |= POLLOUT;
//fsm21可读说明2可读,在可读状态下监测有没有被读成功
if (fsm21.state == STATE_R)
pfd[1].events |= POLLIN;
//监视
while (poll(pfd, 2, -1) < 0) //不像select,这里可以用while
{
if (errno == EINTR)
continue
perror("poll()");
exit(1);
}
//查看监视结果
//如果1被读或者2被写了,驱动状态机改变状态
if (pfd[0].revents & POLLIN || pfd[1].revents & POLLOUT)
fsm_driver(&fsm12);
//如果2被读或者1被写了,驱动状态机改变状态
if (pfd[1].revents & POLLIN || pfd[0].revents & POLLOUT)
fsm_driver(&fsm21);
}
epoll
epoll是为处理大批量句柄而作了改进的poll,poll是在用户态自己操作一个数组,而epoll是在内核态创建一个数组,然后给几个系统调用函数接口供用户使用。
int epoll_create(int size);
//创建一个epoll的句柄,size用来告诉内核这个监听的数目一共有多大。
//注意创建好的epoll会占用一个fd,所以最后得用close释放
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
//op是对要监听的fd操作的行为,有下面三个
//EPOLL_CTL_ADD(增加)、EPOLL_CTL_MOD(修改)、EPOLL_CTL_DEL(删除)
//event表示要监听什么事件
int epoll_wait(int epfd, struct epoll_event *events,
int maxevents, int timeout);
//等待事件的产生,类似于select()调用。参数events用来从内核得到事件的集合,maxevents告之内核这个events有多大,这个 maxevents的值不能大于创建epoll_create()时的size。timeout不用说了
epoll改写数据交换代码
struct epoll_event ev;
int epfd = epoll_create(10); //if (epfd < 0) {}
ev.events = 0;
ev.date.fd = fd1;
epoll_ctl(epfd, EPOLL_CTL_ADD, fd1, &ev);
ev.events = 0;
ev.date.fd = fd2;
epoll_ctl(epfd, EPOLL_CTL_ADD, fd2, &ev);
while (fsm12.state != STATE_T || fsm21.state != STATE_T)
{
//布置监视任务
ev.data.fd = fd1;
ev.events = 0;
if (fsm12.state == STATE_R)
ev.events |= EPOLLIN;
if (fsm21.state == STATE_W)
ev.events |= EPOLLOUT;
epoll_ctl(epfd, EPOLL_CTL_MOD. fd1. &ev);
ev.data.fd = fd2;
ev.events = 0;
if (fsm12.state == STATE_W)
ev.events |= EPOLLOUT;
if (fsm21.state == STATE_R)
ev.events |= EPOLLIN;
//监视
while (epoll_wait(epfd, &ev, 1, -1) < 0)
{
if (errno == EINTR)
continue
perror("epoll()");
exit(1);
}
//查看监视结果
if ((ev.data.fd == fd1 && ev.events & EPOLLIN) ||
(ev.data.fd == fd2 && ev.events & EPOLLOUT))
fsm_driver(&fsm12);
if ((ev.data.fd == fd2 && ev.events & EPOLLIN) ||
(ev.data.fd == fd1 && ev.events & EPOLLOUT))
fsm_driver(&fsm21);
}
close(epfd);
其他读写函数
read()和write()系统调用每次在文件和进程的地址空间之间传送一块连续的数据。但是,应用有时也需要将分散在内存多处地方的数据连续写到文件中。如果要从文件中读一片连续的数据至进程的不同区域,使用read()则要么一次将它们读至一个较大的缓冲区中,然后将它们分成若干部分复制到不同的区域,要么调用read()若干次分批将它们读至不同区域。同样,如果想将程序中不同区域的数据块连续地写至文件,也必须进行类似的处理。
UNIX提供了另外两个函数readv()和writev(),它们只需一次系统调用就可以实现在文件和进程的多个缓冲区之间传送数据,免除了多次系统调用或复制数据的开销。
分散读readv、聚集写writev
ssize_t readv(int fd, const struct iovec *iov, int iovcnt);
ssize_t writev(int fd, const struct iovec *iov, int iovcnt);
iov是个结构数组,它的每个元素指明存储器中的一个缓冲区。结构类型iovec有下述成员,分别给出缓冲区的起始地址和字节数:
struct iovec {
void *iov_base; /* 缓冲区的起始地址 */
size_t iov_len; /* 缓冲区的大小 */
}
iovcnt指出数组iov的元素个数,元素个数至多不超过IOV_MAX。返回值:调用成功时返回读/写的总字节数。失败返回-1,设置errno
writev以顺序iov[0]、iov[1]、…、iov[iovcnt–1]从各缓冲区中聚集输出数据到fd
readv()则将fd读入的数据按同样的顺序散布到各缓冲区中。注意:readv总是先填满一个缓冲区再填充下一个
使用样例
//writev.c
int main()
{
char *str1 = "12345";
char *str2 = "6789";
struct iovec iov[2];
iov[0].iov_base = str1;
iov[0].iov_len = strlen(str1);
iov[1].iov_base = str2;
iov[1].iov_len = strlen(str2);
int cnt = writev(1, iov, 2);
printf("\ntotal %d num\n", cnt);
exit(0);
}
存储映射I/O
存储映射I/O能将一个磁盘文件映射到存储空间的一个缓冲区上,于是当从缓冲区读取数据时,就相当于读文件中相应的字节。将数据存入缓冲区时,相应字节就自动写入文件。这样就可以在不使用read和write的情况下执行I/O。内核将一个给定的文件映射到一个存储区域是由mmap函数实现
void *mmap(void *addr, size_t length, int prot, int flags,
int fd, off_t offset);
start:映射区的开始地址。
length:映射区的长度。
fd:有效的文件描述词。如果MAP_ANONYMOUS被设定,为了兼容问题,其值应为-1。
offset:被映射对象内容的起点。
prot:期望的内存保护标志,不能与文件的打开模式冲突。是以下的某个值,可以通过or运算合理地组合在一起
- PROT_EXEC //页内容可以被执行
- PROT_READ //页内容可以被读取
- PROT_WRITE //页可以被写入
- PROT_NONE //页不可访问
flags:指定映射对象的类型,映射选项和映射页是否可以共享。它的值可以是一个或者多个以下位的组合体
- MAP_SHARED 与其它所有映射这个对象的进程共享映射空间。对共享区的写入,相当于输出到文件。直到msync()或者munmap()被调用,文件实际上不会被更新。
- MAP_PRIVATE 建立一个写入时拷贝的私有映射。内存区域的写入不会影响到原文件。这个标志和以上标志是互斥的,只能使用其中一个。
- MAP_ANONYMOUS 匿名映射,映射区不与任何文件关联,所以fd设置为-1。使用这个flag,mmap的功能就类似于malloc。
使用实例1
将一个文件映射到自己进程空间中,然后读取其中有多少字符a
int main(int argc, char *argv[])
{
if (argc < 2) {}
int fd = open(argv[1], O_RDONLY);
if (fd < 0) {}
struct stat statres; //利用stat获取文件的字符数
if (fstat(fd, &statres) < 0) {}
char *str = mmap(NULL, statres.st_size, PROT_READ, MAP_SHARED, fd, 0);
if (str == MAP_FAILED) {}
close(fd); //已经映射到自己空间了,所以不需要了
int cnt = 0;
for (int i = 0; i < statres.st_size; i++)
{
if (str[i] == 'a')
cnt++;
}
printf("%d\n", cnt);
munmap(str, statres.st_size); //解除映射
exit(0);
}
使用实例2
利用mmap可以实现父子进程间的通信。父进程先调用mmap将一块内存空间映射到自己的进程空间,然后fork子进程,因为子进程是通过复制父进程的方式产生,所以内存空间同时也映射在子进程中。然后就可以利用父写子读或者子读父写的方式通信,所以mmap是个很好用的创建共享内存的方式。注意如果同时读或写要处理竞争。
这里实现一个子写父读的程序
int main()
{
char *ptr = mmap(NULL, MEMSIZE, PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANONYMOUS, -1, 0);
if (ptr == MAP_FAILED) {}
pid_t pid = fork();
if (pid < 0) {}
if (pid == 0) //child
{
//手里捏着的是char指针
//所以现在的写是对字符串的操作
strcpy(ptr, "Hello!");
munmap(ptr, MEMSIZE);
exit(0);
}
else //parent
{
wait(NULL);
//要保证读的时候子进程一定已经写完
//这里要wait,同时完成了收尸的操作
puts(ptr);
munmap(ptr, MEMSIZE);
}
exit(0);
}
文件锁
比如迅雷下载时会先创建一个空洞文件,然后采用多线程并发来填充文件。为了避免多线程出现冲突,这里会使用文件锁。有三个函数可以加文件锁:fcntl、flock和lockf。这里主讲lockf
lockf
lockf只支持排他锁不支持共享锁
int lockf(int fd, int cmd, off_t len);
对fd文件长度为len的内容进行cmd样式的加锁
cmd的取值为:
F_LOCK:给文件互斥加锁,若文件以被加锁,则会一直阻塞到锁被释放。
F_TLOCK:同F_LOCK,但若文件已被加锁,不会阻塞,而回返回错误。
F_ULOCK:解锁。
F_TEST:测试文件是否被上锁,若文件没被上锁则返回0,否则返回-1。
len:为从文件当前位置的起始要锁住的长度。
使用样例
改写以前用20个线程给文件中数加1的代码,这次用多进程
#define FNAME "./test.txt"
#define PRONUM 20
#define LINESIZE 1024
static void func_add()
{
FILE* fp = fopen(FNAME, "r+");
if (fp == NULL) {}
int fd = fileno(fp);
if (fd < 0) {}
char linebuf[LINESIZE];
lockf(fd, F_LOCK, 0); //加锁
fgets(linebuf, LINESIZE, fp);
fseek(fp, 0, SEEK_SET);
fprintf(fp, "%d\n", atoi(linebuf) + 1);
//fprintf是行缓冲,所以在解锁前刷新,将内容强制写进去
fflush(fp);
lockf(fd, F_ULOCK, 0); //解锁
fclose(fp);
return;
}
int main()
{
pid_t pid;
for (int i = 0; i < PRONUM; i++)
{
pid = fork();
if (pid < 0) {}
if (pid == 0)
{
func_add();
exit(0);
}
}
for (int i = 0; i < PRONUM; i++)
wait(NULL);
exit(0);
}