Theme NexT works best with JavaScript enabled
0%

操作系统

^ _ ^

进程间有哪些通信方式?

资料

  1. https://www.guru99.com/inter-process-communication-ipc.html
  2. 进程间的五种通信方式
  3. 套接字
  4. Windows RPC–远程过程调用

管道

  1. 通常管道指的是无名管道,它是半双工的(即数据只能在一个方向上流动),具有固定的读端和写端。
  2. 它只能用于具有亲缘关系的进程之间的通信(也是父子进程或者兄弟进程之间)。
  3. 它可以看成是一种特殊的文件,对于它的读写也可以使用普通的 readwrite 等函数。但是它不是普通的文件,并不属于其他任何文件系统,并且只存在于内存中。

原型

1
2
#include <unistd.h>
int pipe(int fd[2]); // 返回值:若成功返回0,失败返回-1

当一个管道建立时,它会创建两个文件描述符:fd[0]为读而打开,fd[1]为写而打开。
要关闭管道只需将这两个文件描述符关闭即可。

FIFO

  1. FIFO,也称为命名管道,它是一种文件类型。
  2. FIFO可以在无关的进程之间交换数据,与无名管道不同。
  3. FIFO有路径名与之相关联,它以一种特殊设备文件形式存在于文件系统中。

原型

1
2
3
#include <sys/stat.h>
// 返回值:成功返回0,出错返回-1
int mkfifo(const char *pathname, mode_t mode);

其中的 mode 参数与 open 函数中的 mode 相同。一旦创建了一个 FIFO ,就可以用一般的文件I/O函数操作它。
当 open 一个 FIFO 时,是否设置非阻塞标志( O_NONBLOCK )的区别:

  • 若没有指定 O_NONBLOCK (默认),只读 open 要阻塞到某个其他进程为写而打开此 FIFO。类似的,只写 open 要阻塞到某个其他进程为读而打开它。
  • 若指定了 O_NONBLOCK ,则只读 open 立即返回。而只写 open 将出错返回 -1 如果没有进程已经为读而打开该 FIFO ,其 errnoENXIO

消息队列

  1. 消息队列,是消息的链接表,存放在内核中。一个消息队列由一个标识符(即队列ID)来标识。
  2. 消息队列是面向记录的,其中的消息具有特定的格式以及特定的优先级。
  3. 消息队列独立于发送与接收进程。进程终止时,消息队列及其内容并不会被删除。
  4. 消息队列可以实现消息的随机查询,消息不一定要以先进先出的次序读取,也可以按消息的类型读取。

原型

1
2
3
4
5
6
7
8
9
#include <sys/msg.h>
// 创建或打开消息队列:成功返回队列ID,失败返回-1
int msgget(key_t key, int flag);
// 添加消息:成功返回0,失败返回-1
int msgsnd(int msqid, const void *ptr, size_t size, int flag);
// 读取消息:成功返回消息数据的长度,失败返回-1
int msgrcv(int msqid, void *ptr, size_t size, long type,int flag);
// 控制消息队列:成功返回0,失败返回-1
int msgctl(int msqid, int cmd, struct msqid_ds *buf);

在以下两种情况下,msgget将创建一个新的消息队列:

  • 如果没有与键值 key 相对应的消息队列,并且 flag 中包含了 IPC_CREAT 标志位。
  • key参数为 IPC_PRIVATE

函数 msgrcv 在读取消息队列时,type参数有下面几种情况:

  • type == 0 , 返回队列中的第一个消息;
  • type > 0 , 返回队列中消息类型为 type 的第一个消息;
  • type < 0 , 返回队列中消息类型值小于或等于 type 绝对值的消息,如果有多个,则取类型值最小的消息。

信号量

  1. 信号量(semaphore)与已经介绍过的 IPC 结构不同,它是一个计数器。信号量用于实现进程间的互斥与同步,而不是用于存储进程间通信数据。
  2. 信号量用于进程间同步,若要在进程间传递数据需要结合共享内存。
  3. 信号量基于操作系统的 PV 操作,程序对信号量的操作都是原子操作。
  4. 每次对信号量的 PV 操作不仅限于对信号量值加 1 或减 1,而且可以加减任意正整数。
  5. 支持信号量组。

原型

1
2
3
4
5
6
7
#include <sys/sem.h>
// 创建或获取一个信号量组:若成功返回信号量集ID,失败返回-1
int semget(key_t key, int num_sems, int sem_flags);
// 对信号量组进行操作,改变信号量的值:成功返回0,失败返回-1
int semop(int semid, struct sembuf semoparray[], size_t numops);
// 控制信号量的相关信息
int semctl(int semid, int sem_num, int cmd, ...);

共享内存

  1. 共享内存(Shared Memory),指两个或多个进程共享一个给定的存储区。
  2. 共享内存是最快的一种 IPC,因为进程是直接对内存进行存取。
  3. 因为多个进程可以同时操作,所以需要进行同步。
  4. 信号量+共享内存通常结合在一起使用,信号量用来同步对共享内存的访问。

原型

1
2
3
4
5
6
7
8
9
#include <sys/shm.h>
// 创建或获取一个共享内存:成功返回共享内存ID,失败返回-1
int shmget(key_t key, size_t size, int flag);
// 连接共享内存到当前进程的地址空间:成功返回指向共享内存的指针,失败返回-1
void *shmat(int shm_id, const void *addr, int flag);
// 断开与共享内存的连接:成功返回0,失败返回-1
int shmdt(void *addr);
// 控制共享内存的相关信息:成功返回0,失败返回-1
int shmctl(int shm_id, int cmd, struct shmid_ds *buf);

当用shmget函数创建一段共享内存时,必须指定其 size;而如果引用一个已存在的共享内存,则将 size 指定为0 。

当一段共享内存被创建以后,它并不能被任何进程访问。必须使用shmat函数连接该共享内存到当前进程的地址空间,连接成功后把共享内存区对象映射到调用进程的地址空间,随后可像本地空间一样访问。

shmdt函数是用来断开shmat建立的连接的。注意,这并不是从系统中删除该共享内存,只是当前进程不能再访问该共享内存而已。

套接字

  1. 套接字(socket)是一个抽象层,应用程序可以通过它发送或接收数据,可对其进行像对文件一样的打开、读写和关闭等操作。套接字允许应用程序与网络中的其他应用程序进行通信。网络套接字是IP地址与端口的组合。
  2. 实际开发中使用的套接字可以分为三类:流套接字(TCP套接字)数据报套接字原始套接字
    • 流式套接字(SOCK-STREAM)。它提供了一种可靠的、可以进行双向连接的数据传输服务。其实现了数据无差错、无重复的发送。流式套接字自身便内设了流量控制功能。在TCP/IP协议簇中,使用TCP协议来实现字节流的传输,当用户想要发送大批量的数据或者对数据传输有较高的要求时,可以使用流式套接字。
    • 数据报套接字(SOCK-DGRAM)。它提供了一种不可靠的双向数据传输服务。数据包以独立的形式被发送,不提供可靠性保证。数据在传输过程中可能会丢失或重复,并且不能保证在接收端按发送顺序接收数据。在TCP/IP协议簇中,使用UDP协议来实现数据报套接字。在出现差错的可能性较小或允许部分传输出错的应用场合,可以使用数据报套接字进行数据传输,这样通信的效率较高。
    • 原始套接字(SOCK-RAW)。该套接字允许对较低层协议(如IP或ICMP)进行直接访问,常用于网络协议分析,检验新的网络协议实现,也可用于测试新配置或安装的网络设备。

远程过程调用

RPC( Remote Procedure Call),远程过程调用,相比于IPC来说RPC就是基于远程的工作机制,说白了RPC也是一种进程间通信方式,它只不过可以允许本地程序调用另一个地址空间的过程或者函数,而不用程序员去管理调用的细节。对于IPC来说,程序只能调用本地空间的函数,而RPC机制提供了一种程序员不必显示的区分本地调用和远程调用。

RPC框架

  • 客户端(client):服务的调用方。
  • 客户端存根(client stub):存放服务端的地址信息,再将客户端的请求参数打包成网络数据,然后通过网络远程发送给服务方。
  • 服务端存根(server stub):接受客户端发送过来的消息,将消息解包,并调用本地的方法。
  • 服务端(server):正真的服务提供者。

进程和线程之间有什么区别?

进程是资源分配的最⼩单位,⽽线程则是系统调度的最⼩单位。

  1. 创建时消耗资源不同:进程下多个线程间共享虚拟内存⽂件描述符信号处理⽅式等资源,但进程间拥有独⽴的虚拟内存、⽂件描述符与信号处理等资源。在创建线程时,由于虚拟内存、⽂件描述符等资源共享,故不需要进⾏额外的内存复制。
  2. 通信⽅式不同:线程间可通过全局变量、互斥锁或者是条件变量来进⾏通信,但进程间只能使⽤管道、OS 提供的共享内存等进⾏通信,需要投⼊更多的资源。
  3. 对信号的⽀持不同:由于线程是“依附”在进程之上的,因此,同⼀个进程下的多个线程在使⽤信号时会有问题,⽆法准确的将信号传递⾄某⼀个具体的线程。
  4. 上下⽂切换速度不同:因为线程间共享了虚拟内存、⽂件描述符等诸多信息,因此 OS 只需要在上下⽂保存线程的堆栈、寄存器等少量信息,所以其切换速度要⾼于进程间的上下⽂切换。

简述 select, poll, epoll 的使用场景以及区别

进程可以通过 select、poll、epoll 发起 I/O 多路复用的系统调用,这些系统调用都是同步阻塞的:如果传入的多个文件描述符中,有描述符就绪,则返回就绪的描述符;否则如果所有文件描述符都未就绪,就阻塞调用进程,直到某个描述符就绪,或者阻塞时长超过设置的 timeout 后,再返回。使用非阻塞 I/O 检查每个描述符的就绪状态。

如果 timeout 参数设为 NULL,会无限阻塞直到某个描述符就绪;如果 timeout 参数设为 0,会立即返回,不阻塞。

文件描述符

文件描述符(file descriptor)是一个非负整数,从 0 开始。进程使用文件描述符来标识一个打开的文件。

系统为每一个进程维护了一个文件描述符表,表示该进程打开文件的记录表,而文件描述符实际上就是这张表的索引。当进程打开(open)或者新建(create)文件时,内核会在该进程的文件列表中新增一个表项,同时返回一个文件描述符 —— 也就是新增表项的下标。

每个进程默认都有 3 个文件描述符:0 (stdin)、1 (stdout)、2 (stderr)。

select

函数原型

1
2
3
4
5
6
int select(int nfds,    // 需要遍历的文件描述符个数
fd_set *restrict readfds, // 可以读取的描述符
fd_set *restrict writefds, // 可以写入的描述符
fd_set *restrict errorfds, // 发生错误的描述符
struct timeval *restrict timeout // select 阻塞时长
);

fd_set
参数中的 fd_set 类型表示文件描述符的集合。由于文件描述符 fd 是一个从 0 开始的无符号整数,所以可以使用 fd_set 的二进制每一位来表示一个文件描述符。某一位为 1,表示对应的文件描述符已就绪。

fd_set 的使用涉及以下几个 API:

1
2
3
4
5
#include <sys/select.h>   
int FD_ZERO(int fd, fd_set *fdset); // 将 fd_set 所有位置 0
int FD_CLR(int fd, fd_set *fdset); // 将 fd_set 某一位置 0
int FD_SET(int fd, fd_set *fd_set); // 将 fd_set 某一位置 1
int FD_ISSET(int fd, fd_set *fdset); // 检测 fd_set 某一位是否为 1

select 的缺点

  1. 性能开销大:调用 select 时会陷入内核,这时需要将参数中的 fd_set 从用户空间拷贝到内核空间;内核需要遍历传递进来的所有 fd_set 的每一位,不管它们是否就绪。
    2、 同时能够监听的文件描述符数量太少。受限于 sizeof(fd_set) 的大小,在编译内核时就确定了且无法更改。一般是 1024,不同的操作系统不相同。

poll

poll 和 select 几乎没有区别。poll 采用链表的方式存储文件描述符,没有最大存储数量的限制。
从性能开销上看,poll 和 select 的差别不大。

epoll

epoll 是对 select 和 poll 的改进,避免了“性能开销大”和“文件描述符数量少”两个缺点。
简而言之,epoll 有以下几个特点:

  1. 使用红黑树存储文件描述符集合。
  2. 使用队列存储就绪的文件描述符。
  3. 每个文件描述符只需在添加时传入一次;通过事件更改文件描述符状态。

select、poll 模型都只使用一个函数,而 epoll 模型使用三个函数:epoll_createepoll_ctlepoll_wait

epoll_create

1
2
// 创建一个 epoll 实例,同时返回一个引用该实例的文件描述符。
int epoll_create(int size);

返回的文件描述符仅仅指向对应的 epoll 实例,并不表示真实的磁盘文件节点。其他 API 如 epoll_ctl、epoll_wait 会使用这个文件描述符来操作相应的 epoll 实例。

epoll 实例内部存储:

  • 监听列表:所有要监听的文件描述符,使用红黑树
  • 就绪列表:所有就绪的文件描述符,使用链表

epoll_ctl

1
2
// epoll_ctl 会监听文件描述符 fd 上发生的 event 事件。返回值 0 或 -1,表示上述操作成功与否。
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

参数说明:

  • epfd 即 epoll_create 返回的文件描述符,指向一个 epoll 实例
  • fd 表示要监听的目标文件描述符
  • event 表示要监听的事件(可读、可写、发送错误…)
  • op 表示要对 fd 执行的操作,有以下几种:
    • EPOLL_CTL_ADD:为 fd 添加一个监听事件 event
    • EPOLL_CTL_MOD:event 是一个结构体变量,这相当于变量 event 本身没变,但是更改了其内部字段的值
    • EPOLL_CTL_DEL:删除 fd 的所有监听事件,这种情况下 event 参数没用

epoll_ctl 会将文件描述符 fd 添加到 epoll 实例的监听列表里,同时为 fd 设置一个回调函数,并监听事件 event。当 fd 上发生相应事件时,会调用回调函数,将 fd 添加到 epoll 实例的就绪队列上。

epoll_wait

1
2
int epoll_wait(int epfd, struct epoll_event *events,
int maxevents, int timeout);

这是 epoll 模型的主要函数,功能相当于 select。

参数说明:

  • epfd 即 epoll_create 返回的文件描述符,指向一个 epoll 实例
  • events 是一个数组,保存就绪状态的文件描述符,其空间由调用者负责申请
  • maxevents 指定 events 的大小
  • timeout 类似于 select 中的 timeout。如果没有文件描述符就绪,即就绪队列为空,则 epoll_wait 会阻塞 timeout 毫秒。如果 timeout 设为 -1,则 epoll_wait 会一直阻塞,直到有文件描述符就绪;如果 timeout 设为 0,则 epoll_wait 会立即返回
  • 返回值表示 events 中存储的就绪描述符个数,最大不超过 maxevents。

epoll 的优点

对于“文件描述符数量少”,select 使用整型数组存储文件描述符集合,而 epoll 使用红黑树存储,数量较大。

对于“性能开销大”,epoll_ctl 中为每个文件描述符指定了回调函数,并在就绪时将其加入到就绪列表,因此 epoll 不需要像 select 那样遍历检测每个文件描述符,只需要判断就绪列表是否为空即可。这样,在没有描述符就绪时,epoll 能更早地让出系统资源。

此外,每次调用 select 时都需要向内核拷贝所有要监听的描述符集合,而 epoll 对于每个描述符,只需要在 epoll_ctl 传递一次,之后 epoll_wait 不需要再次传递。这也大大提高了效率。

三者对比

  • select:调用开销大(需要复制集合);集合大小有限制;需要遍历整个集合找到就绪的描述符
  • poll:poll 采用链表的方式存储文件描述符,没有最大存储数量的限制,其他方面和 select 没有区别
  • epoll:调用开销小(不需要复制);集合大小无限制;采用回调机制,不需要遍历整个集合

select、poll 都是在用户态维护文件描述符集合,因此每次需要将完整集合传给内核;epoll 由操作系统在内核中维护文件描述符集合,因此只需要在创建的时候传入文件描述符。

此外 select 只支持水平触发,epoll 支持边缘触发。

当连接数较多并且有很多的不活跃连接时,epoll 的效率比其它两者高很多。当连接数较少并且都十分活跃的情况下,由于 epoll 需要很多回调,因此性能可能低于其它两者。

水平触发、边缘触发

水平触发(LT,Level Trigger):当文件描述符就绪时,会触发通知,如果用户程序没有一次性把数据读/写完,下次还会发出可读/可写信号进行通知。
边缘触发(ET,Edge Trigger):仅当描述符从未就绪变为就绪时,通知一次,之后不会再通知。

简述操作系统如何进行内存管理

1. 虚拟内存
内存管理就在程序和物理内存之间引入了虚拟内存的概念;对进程地址和物理地址进行隔离。

2. 内存分区
Linux 对内存节点进行分区;将节点分为DMA、Normal、High Memory 内存区。DMA内存区:直接内存访问区,通常为物理内存的起始16M;主要供I/O外设使用,无需CPU参与的外设和内存DMA。Normal内存区:从16M到896M内存区;内核可以直接使用。Hight Memory内存区:896M以后的内存区;高端内存,内核不能直接使用。

3. 内核空间和用户空间
Linux 操作系统,将虚拟内存划分为内核空间和用户空间;用户进程只能访问用户空间的虚拟地址,只有通过系统调用、外设中断或异常才能访问内核空间。

Linux内核空间 1G容量,包括:内核镜像、物理页面表、驱动程序等,其分区包括:

  • 直接映射区
  • 高端内存线性地址空间
  • 动态内存映射区(vmalloc region):由内核函数vmalloc 分配;
  • 永久内存映射区:alloc_page、 kmap
  • 固定映射区:特定用途,如 ACPI_BASE 等

用户空间:分为5个不同内存区域:

  • 代码段:只读,存放可执行文件的操作指令;镜像;
  • 数据段:存放可执行文件中已初始化全局变量;存放静态变量和全局变量;
  • BSS段:未初始化全局变量
  • 堆:存放被动态分配的内存段;
  • 栈:存放临时创建的局部变量;

4. 内存地址映射
CPU生成的地址是逻辑地址,而内存单元中的地址为物理地址;执行时地址绑定方案会生成不同的逻辑地址和物理地址,这时,逻辑地址通常被称为虚拟地址。

物理地址空间是有限的,虚拟地址空间可以是任意大小;程序可以通过操作虚拟地址,把虚拟地址空间映射到物理地址空间; Linux通过缺页中断和swap机制,实现虚拟地址映射。

虚拟地址和物理地址,主要通过分段和分页技术,进行映射;程序地址:段号+页号+页内偏移。

段是信息的逻辑单位,根据用户的需要划分,段对用户是可见的; 页时信息的物理单位,为管理内存方便和划分的,对用户透明的。分段:将程序分为代码段、数据段、堆栈段等;分页:将段分成均匀的小块,通过页表映射物理内存。