OS基础总结 Extend

194 阅读16分钟

OS Extend


fork

  1. fork() 是创建进程函数。
  2. c程序一开始,就会产生一个进程,当这个进程执行到fork()的时候,会创建一个子进程。
  3. 此时父进程和子进程是共存的,它们俩会一起向下执行c程序的代码。
  4. 子进程创建成功后,fork是返回两个值,一个代表父进程,一个代表子进程:代表父进程的值是一串数字,这串数字是子进程的ID(地址);一个代表子进程,值为0。

open与fopen

 

非缓冲文件系统(open)

非缓冲文件系统依赖于操作系统(系统函数),通过操作系统的功能对文件进行读写,是系统级的输入输出,它不设文件结构体指针,只能读写二进制文件,但效率高、速度快,由于ANSI标准不再包括非缓冲文件系统,因此建议大家最好不要选择它。函数包括open, close, read, write, fcntl, getc, getchar, putc, putchar等。

缓冲文件系统(fopen)

缓冲文件系统是借助文件结构体指针来对文件进行管理(库函数),通过文件指针来对文件进行访问,既可以读写字符、字符串、格式化数据,也可以读写二进制数据。缓冲文件系统的特点是:在内存开辟一个“缓冲区”,为程序中的每一个文件使用,当执行读文件的操作时,从磁盘文件将数据先读入内存“缓冲区”, 装满后再从内存“缓冲区”依此读入接收的变量。执行写文件的操作时,先将数据写入内存“缓冲区”,待内存“缓冲区”装满后再写入文件。由此可以看出,内存 “缓冲区”的大小,影响着实际操作外存的次数,内存“缓冲区”越大,则操作外存的次数就少,执行速度就快、效率高。一般来说,文件“缓冲区”的大小随机器 而定。函数包括fopen, fclose, fread, fwrite, fgetc, fgets, fputc, fputs, freopen, fseek, ftell, rewind等。

缓冲

  • 非缓冲IO操作数据流向路径:数据 → 内核缓冲区 → 磁盘
  • 带缓冲IO操作数据流向路径:数据 → 流缓冲区 → 内核缓冲区 → 磁盘
  • 无缓存只不过是指在用户层没有缓存,但对于内核来说,还是进行了缓存

open与fopen的区别

  • 前者属于低级IO,后者是高级IO。

  • 前者返回一个文件描述符(作用域为当前进程),后者返回一个文件指针。

  • 前者无缓冲,后者有缓冲。

  • 前者与 read, write 等配合使用, 后者与 fread, fwrite等配合使用。

  • 后者是在前者的基础上扩充而来的,在大多数情况下,用后者。

  • 设备文件不可以当成流式文件来用,只能用open;fopen是用来操纵正规文件的,并且设有缓冲的。一般用fopen打开普通文件,用open打开设备文件。

  • fopen和open最主要的区别是fopen在用户态下就有了缓存,在进行读和写的时候减少了用户态和内核态的切换,而open则每次都需要进行内核态和用户态的切换;表现为,如果顺序访问文件,fopen系列的函数要比直接调用open系列快;如果随机访问文件open要比fopen快。


I/O模型

  • 5种I/O处理模型  
IO处理模型{阻塞I/O非阻塞I/OI/O多路复用(epollselect都是一种I/O复用机制)信号驱动I/O异步I/OIO处理模型\left\{ \begin{aligned} &阻塞I/O\\ &非阻塞I/O\\ &I/O多路复用(epoll、select都是一种I/O复用机制)\\ &信号驱动I/O\\ &异步I/O \end{aligned} \right.

IO模型浅析-阻塞、非阻塞、IO复用、信号驱动、异步IO、同步IO


I/O多路复用

  • IO多路复用的概念
    多路复用是一种机制,可以用来监听多种描述符,如果其中任意一个描述符处于就绪的状态,就会返回消息给对应的进程通知其采取下一步的操作。

  • IO多路复用的优势
    当进程需要等待多个描述符的时候,通常情况下进程会开启多个线程,每个线程等待一个描述符就绪,但是多路复用可以同时监听多个描述符,进程中无需开启线程,减少系统开销,在这种情况下多路复用的性能要比使用多线程的性能要好很多。

  • 相关API介绍
    在linux中,关于多路复用的使用,有三种不同的API,select、pollepol

select介绍

select的使用需要引入sys/select.h头文件,API函数比较简单,函数原型如下:

    int select(int nfds, fd_set *readfds, fd_set *writefds,
                fd_set *exceptfds, struct timeval *timeout);
  • 返回值: 准备就绪的描述符数,若超时则返回0,若出错则返回-1。
  • select的调用会阻塞到有文件描述符可以进行IO操作或被信号打断或者超时才会返回。
  • nfds是一个整数值,是指fd_set集合中所有文件描述符的范围,即所有文件描述符的最大值加1,当调用select的时候,内核态会判断fd_set中描述符是否就绪,nfds告诉内核最多判断到哪一个描述符。
  • select将监听的文件描述符分为三组,每一组监听不同的需要进行的IO操作。readfds是需要进行读操作的文件描述符,writefds是需要进行写操作的文件描述符,exceptfds是需要进行异常事件处理的文件描述符。这三个参数可以用NULL来表示对应的事件不需要监听。
  • select可同时监听的文件描述符数量是通过FS_SETSIZE来限制的,在Linux系统中,该值为1024,当然我们可以增大这个值,但随着监听的文件描述符数量增加,select的效率会降低。

fd_set

其中有一个很重要的结构体fd_set,该结构体可以看作是一个描述符的集合,可以将fd_set看作是一个位图,类似于操作系统中的位图,其中每个整数的每一bit代表一个描述符,。

举个简单的例子,fd_set中元素的个数为2,初始化都为0,则fd_set中含有两个整数0,假设一个整数的长度8位,则展开fd_set的结构就是 00000000 0000000,如果这个时候添加一个描述符为3,则对应fd_set编程 00000000 00001000,可以看到在这种情况下,第一个整数标记描述符0~7,第二个整数标记8~15,依次类推。

  • fd_set有四个关联的api
void FD_ZERO(fd_set *fdset) //清空fdset,将所有bit置为0
void FD_SET(int fd, fd_set *fdset) //将fd对应的bit置为1
void FD_CLR(int fd, fd_set *fdset) //将fd对应的bit置为0
void FD_ISSET(int fd, fd_set *fdset) //判断fd对应的bit是否为1,也就是fd是否就绪

timeval

struct timeval {
    long tv_sec;    //秒
    long tv_usec;    //微秒
}
  • 参数timeout指定select的工作方式:
    • timeout=NULL,表示select永远等待下去,直到其中至少存在一个描述符就绪
    • timeout结构体中秒或者微妙是一个大于0的整数,表示select等待一段固定的事件,若该短时间内未有描述符就绪则返回
    • timeout= 0,表示不等待,直接返回

poll

select中,每个fd_set结构体最多只能标识1024个描述符,在poll中去掉了这种限制,使用poll需要引入头文件sys/poll.h,poll调用的API如下:

int poll(struct pollfd *fds, nfds_t nfds, int timeout);

struct pollfd {
    int fd;                  // poll的文件描述符
    short int events;        // poll关心的事件类型
    short int revents;       // 发生的事件类型
  };

可以看到,poll中使用结构体保存一个文件描述符关心的事件,而在select中,统一使用fd_set,一个fd_set中可以是所有需要监听读事件的文件描述符,也可以是所有需要写事件的文件描述符。 相比来说,poll比select更加的灵活,在调用poll之后,无需像select一样需要重新对文件描述符初始化,因为poll返回的事件写在了pollfd->revents成员中。

  • 函数返回
    poll函数返回产生事件的描述符的数量,如果返回0表示超时,如果为-1表示产生错误

epoll

epoll中,使用一个描述符来管理多个文件描述符,使用epoll需要引入头文件sys/epoll.h,epoll相关的api函数如下:

int epoll_create (int size);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);

epoll_create

int epoll_create (int  size);

epoll_create函数创建一个epoll实例并返回,该实例可以用于监控size个文件描述符。

epoll_ctl

int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

该函数用来向epoll中注册事件函数,进行红黑树添加节点操作,其中epfd为epoll_create返回的epoll实例,op表示要进行的操作,可能的取值EPOLL_CTL_ADD 注册、EPOLL_CTL_MOD 修 改、EPOLL_CTL_DEL 删除,fd为要进行监控的文件描述符,event告诉内核要监听什么事件。 该函数如果调用成功返回0,否则返回-1。

epoll_wait

int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
  • epoll_wait类似与select中的select函数、poll中的poll函数,等待内核返回监听描述符的事件产生,其中:
    • epfd是epoll_create创建的epoll实例;
    • events数组为epoll_wait要返回的已经产生的事件集合,其中第i个元素成员的events[i]->data->fd表示产生该事件的描述符;
    • maxevents为希望返回的最大的事件数量(通常为events的大小);
    • timeout和poll中的timeout相同。该函数返回已经就绪的事件的数量,如果为-1表示出错。

详见🔗

水平触发和边沿触发

  • 水平触发表示只要有IO操作可以进行(比如某个文件描述符有数据可读),每次调用epoll_wait都会返回以通知程序可以进行IO操作;缺省工作方式,即默认的工作方式,支持blocksocket和no_blocksocket,错误率比较小。
  • 边沿触发表示只有在文件描述符状态发生变化时,调用epoll_wait才会返回,如果第一次没有全部读完该文件描述符的数据而且没有新数据写入,再次调用epoll_wait都不会有通知给到程序,因为文件描述符的状态没有变化。高速工作方式,错误率比较大,只支持no_block socket (非阻塞socket)
  • select和poll都是状态持续通知的机制,且不可改变,只要文件描述符中有IO操作可以进行,那么select和poll都会返回以通知程序。而epoll两种通知机制可选。

然后详细解释ET, LT:

  • 没有对就绪的fd进行IO操作,内核会不断的通知。 LT(leveltriggered)是缺省的工作方式, 并且同时支持block和no-blocksocket 。在这种做法中,内核告诉你一个文件描述符是否就绪了,然后你可以对这个就绪的fd进行IO操作。如果你不作任何操作,内核还是会继续通知你的,所以,这种模式编程出错误可能性要小一点。传统的select/poll都是这种模型的代表。
  • 没有对就绪的fd进行IO操作,内核不会再进行通知。 ET(edge-triggered)是 高速工作方式 ,只支持no-blocksocket。在这种模式下,当描述符从未就绪变为就绪时,内核通过epoll告诉你。然后它会假设你知道文件描述符已经就绪,并且不会再为那个文件描述符发送更多的就绪通知,直到你做了某些操作导致那个文件描述符不再为就绪状态了(比如,你在发送,接收或者接收请求,或者发送接收的数据少于一定量时导致了一个EWOULDBLOCK错误)。但是请注意,如果一直不对这个fd作IO操作(从而导致它再次变成未就绪),内核不会发送更多的通知(only once)。

不同IO多路复用方案优缺点

poll vs select

poll和select基本上是一样的,poll相比select好在如下几点:

  1. poll传参对用户更友好。比如不需要和select一样计算很多奇怪的参数比如nfds(值最大的文件描述符+1),再比如不需要分开三组传入参数。
  2. poll会比select性能稍好些,因为select是每个bit位都检测,假设有个值为1000的文件描述符,select会从第一位开始检测一直到第1000个bit位。但poll检测的是一个数组。
  3. select的时间参数在返回的时候各个系统的处理方式不统一,如果希望程序可移植性更好,需要每次调用select都初始化时间参数。

而select比poll好在下面几点:

  1. 支持select的系统更多,兼容更强大,有一些unix系统不支持poll。
  2. select提供精度更高(到microsecond)的超时时间,而poll只提供到毫秒的精度。 但总体而言 select和poll基本一致。

epoll vs poll&select

epoll优于select&poll在下面几点:

  1. 在需要同时监听的文件描述符数量增加时,select&poll是O(N)的复杂度,epoll是O(1),在N很小的情况下,差距不会特别大,但如果N很大的前提下,一次O(N)的循环可要比O(1)慢很多,所以高性能的网络服务器都会选择epoll进行IO多路复用。
  2. epoll内部用一个文件描述符挂载需要监听的文件描述符,这个epoll的文件描述符可以在多个线程/进程共享,所以epoll的使用场景要比select&poll要多。

select、poll、epoll比较

  • select和poll的机制基本相同,只不过poll没有select最大文件描述符的限制,在具体使用的时候,有如下缺点:

    • 每次调用select或者poll,都需要将监听的fd_set或者pollfd发送给内核态,如果需要监听大量的文件描述符,这样的效率很低;
    • 在内核态中,每次需要对传入的文件描述符进行轮询,查询是否有对应的事件产生。
  • epoll的高效在于将这些分开,首先epoll不是在每次调用epoll_wait的时候,将描述符传送给内核,而是在epoll_ctl的时候传送描述符给内核,当调用epoll_wait的收,不用每次都接收。

  • 不像select和poll使用一个单独的API函数,在epoll中,使用epoll_create创建一个epoll实例,然后当调用epoll_ctl新增监听描述符的时候,这个时候才将用户态的描述符发送到内核态,因为epoll_wait调用的频率肯定要比epoll_create的频率要高,所以当epoll_wait的时候无需传送任何描述符到用户态。

  • 关于第二点,在内核态中,使用一个描述符就绪的链表,当描述符就绪的时候,在内核态中会使用回调函数,该函数会将对应的描述符添加入就绪链表中,那么当epoll_wait调用的时候,就不需要遍历所有的描述符查看是否有就绪的事件,而是直接查看链表是否为空。

socket

在 Linux 下使用 <sys/socket.h> 头文件中 socket() 函数来创建套接字,原型为:
int socket(int af, int type, int protocol);

  1. af 为地址族(Address Family),也就是IP地址类型,常用的有AF_INET AF_INET6AF_INET 表示IPv4地址,例如 127.0.0.1;AF_INET6 表示 IPv6 地址,例如 1030::C9B4:FF12:48AA:1A2B。
  2. type 为数据传输方式/套接字类型,常用的有 SOCK_STREAM(流格式套接字/面向连接的套接字) 和 SOCK_DGRAM(数据报套接字/无连接的套接字)
  3. protocol 表示传输协议,常用的有 IPPROTO_TCP 和 IPPTOTO_UDP,分别表示 TCP 传输协议和 UDP 传输协议。
  • 使用 IPv4 地址,参数 af 的值为 PF_INET。如果使用 SOCK_STREAM 传输数据,那么满足这两个条件的协议只有 TCP,因此可以这样来调用 socket() 函数:

    • int tcp_socket = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); //IPPROTO_TCP表示TCP协议
  • 如果使用 SOCK_DGRAM 传输方式,那么满足这两个条件的协议只有 UDP,因此可以这样来调用 socket() 函数:

    • int udp_socket = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); //IPPROTO_UDP表示UDP协议
      这种套接字称为 UDP 套接字。
  • 上面两种情况都只有一种协议满足条件,可以将 protocol 的值设为 0,系统会自动推演出应该使用什么协议,如下所示:

    • int tcp_socket = socket(AF_INET, SOCK_STREAM, 0); //创建TCP套接字
    • int udp_socket = socket(AF_INET, SOCK_DGRAM, 0); //创建UDP套接字

bind、connect

  • socket() 函数用来创建套接字,确定套接字的各种属性,然后服务器端要用 bind() 函数将套接字与特定的 IP 地址和端口绑定起来,只有这样,流经该 IP 地址和端口的数据才能交给套接字处理。类似地,客户端也要用 connect() 函数建立连接。 tcp.png
    bind() 函数的原型为:
    int bind(int sock, struct sockaddr *addr, socklen_t addrlen);
    sock 为 socket 文件描述符,addr 为 sockaddr 结构体变量的指针,addrlen 为 addr 变量的大小,可由 sizeof() 计算得出。
//创建套接字
int serv_sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);

//创建sockaddr_in结构体变量
struct sockaddr_in serv_addr;
memset(&serv_addr, 0, sizeof(serv_addr));  //每个字节都用0填充
serv_addr.sin_family = AF_INET;  //使用IPv4地址
serv_addr.sin_addr.s_addr = inet_addr("127.0.0.1");  //具体的IP地址
serv_addr.sin_port = htons(1234);  //端口

//将套接字和IP、端口绑定
bind(serv_sock, (struct sockaddr*)&serv_addr, sizeof(serv_addr));
  • connect() 函数
    connect() 函数用来建立连接,它的原型为:
    int connect(int sock, struct sockaddr *serv_addr, socklen_t addrlen);
    各个参数的说明和 bind() 相同,不再赘述。

listen、accept

对于服务器端程序,使用 bind() 绑定套接字后,还需要使用 listen() 函数让套接字进入被动监听状态,再调用 accept() 函数,就可以随时响应客户端的请求了。

  • listen() 函数
    通过 listen() 函数可以让套接字进入被动监听状态,它的原型为:
    int listen(int sock, int backlog);
    sock 为需要进入监听状态的套接字,backlog 为请求队列的最大长度。 所谓被动监听,是指当没有客户端请求时,套接字处于“睡眠”状态,只有当接收到客户端请求时,套接字才会被“唤醒”来响应请求。

    请求队列
    当套接字正在处理客户端请求时,如果有新的请求进来,套接字是没法处理的,只能把它放进缓冲区,待当前请求处理完毕后,再从缓冲区中读取出来处理。如果不断有新的请求进来,它们就按照先后顺序在缓冲区中排队,直到缓冲区满。这个缓冲区,就称为请求队列(Request Queue)。

    注意: listen() 只是让套接字处于监听状态,并没有接收请求。接收请求需要使用 accept() 函数。

  • accept()函数
    当套接字处于监听状态时,可以通过accept()函数来接收客户端请求。它的原型为:
    int accept(int sock, struct sockaddr *addr, socklen_t *addrlen);
    它的参数与listen()和connect()是相同的:sock为服务器端套接字,addr为sockaddr_in结构体变量,addrlen为参数addr的长度,可由sizeof()求得。

    listen() 只是让套接字进入监听状态,并没有真正接收客户端请求,listen() 后面的代码会继续执行,直到遇到accept()。accept()会阻塞程序执行(后面代码不能被执行),直到有新的请求到来。


🍄扩展🍄