目录
- [3. 编码通信](#3. 编码通信)
-
- [3.1 管道的四种情况](#3.1 管道的四种情况)
- [3.2 管道的大小](#3.2 管道的大小)
- [3.3 总结管道的五个特征](#3.3 总结管道的五个特征)
- [4. 管道的应用场景](#4. 管道的应用场景)
-
- [4.1 命令行中的管道](#4.1 命令行中的管道)
- [4.2 进程池中的管道](#4.2 进程池中的管道)
3. 编码通信
c
// 创建管道文件的系统调用
// pipefd:输出型参数,将以读写方式分别打开的文件的文件描述符带出,供用户使用。
// pipefd[0]: 读端下标 pipefd[1]: 写端下标
//创建成功返回0,否则返回-1,并且设置对应的errno
int pipe(int pipefd[2]);
RETURN VALUE
On success, zero is returned. On error, -1 is returned, and errno is set appropriately.
c
// 输出信息格式化后,从原来想显示器文件写入转变为像某一个字符串写入,并且设置写入的大小
// 与 sprintf 相比更加安全的写入
int snprintf(char *str, size_t size, const char *format, ...);
cpp
#include <iostream>
#include <unistd.h>
#include <cstdlib>
#include <string>
#include <cstring>
#include <cstdio>
#include <sys/types.h>
#include <sys/wait.h>
using namespace std;
#define N 2
#define M 1024
void Write(int wfd)
{
string s = "I am a child process!";
pid_t self = getpid();
int number = 0;
char buffer[M];
while (1)
{
snprintf(buffer, sizeof(buffer), "%s-%d-%d", s.c_str(), self, number++);
// 发送信息给父进程
// strlen(buffer) 不需要 + 1 带上 \0,因为 \0 是 C/C++ 的语法规定
// wrtie 向管道文件写入,管道文件没有这个规定,只写入数据内容即可。
write(wfd, buffer, strlen(buffer));
sleep(1);
}
}
void Reader(int rfd)
{
char buffer[M];
while(1)
{
// read 返回实际读取的字符个数
ssize_t n = read(rfd, buffer, sizeof(buffer));
if(n > 0)
{
// 读取到的字符串没有带 \0, 因此补上。
buffer[n] = '\0';
cout << "father process(" << getpid() << ") get the message: " << buffer << endl;
}
// TODO
}
}
int main()
{
int pipefd[N];
int n = pipe(pipefd);
if (n < 0) return 1;
// father->r, child->w
// pipefd[0]: 读端下标 pipefd[1]: 写端下标
pid_t id = fork();
if (id < 0) return 2;
else if (id == 0)
{
// child process
close(pipefd[0]); // 子进程关闭读端的文件描述符
Write(pipefd[1]); // 子进程写
close(pipefd[1]);
exit(0);
}
// father process
close(pipefd[1]); // 父进程先关闭写端的文件描述符
Reader(pipefd[0]); // 父进程读
pid_t returnId = waitpid(id, nullptr, 0); // 等待回收子进程
if(returnId < 0) return 1;
close(pipefd[0]);
return 0;
}
父子进程间的通信,其中子进程要写入数据给父进程读取,依旧上述理论知识,进程间通信就是创建一个管道文件,然后向管道文件写入数据即可,而管道文件也是文件,因此只要是文件,直接调用 write 系统调用写入即可,然后父进程再调 read 读取就可以完成进程间的通信了。
其中,buffer 数组的本质就是用户缓冲区,子进程先将数据写入到用户缓冲区,再调 write 将用户缓冲区刷到内核中(即文件缓冲区,文件对象也是内核的数据结构);父进程就先把文件缓冲区的数据拷贝到应用层,即用户缓冲区(buffer)。所以进程间的通信,是需要操作系统做为中介的,通信的数据都要经过操作系统,因为进程是互相独立的,谁也无法直接访问谁。
3.1 管道的四种情况
-
读写端正常,管道如果为空,读端阻塞。
上述代码中只是对子进程的写入操作做了 sleep,父进程并没有做休眠,但为什么父进程读取从子进程传过来的数据时,也是一秒一秒的读,父进程没有休眠,不应该是一直读取,然后读完打印,再读取。。。。这样的现象吗??
父子在对同一个资源进行访问,是进程通信的前提,但引入一个方案往往会携带一个新的问题产生,这个资源是被多执行流共享的,难免出现访问冲突的问题。假设子进程写入一条数据后 sleep(50),但父进程还是不管不顾的从文件缓冲区中读取数据,可能读取到的都是垃圾数据,总不能都打印出来让用户自己去甄别这些数据的真实性吧。而当一方向文件做读取的同时,另一方如果也在做写入操作,极大困难会导致数据的覆盖等问题,这就是访问冲突的问题,因此父子进程是会进程协同的,同步与互斥的,以达到包含管道文件数据的安全。
所以当子进程写入完后休眠了,父进程就是要阻塞等待子进程,直到子进程做下一次写入,父进程再开始第二遍的读取。
如果反过来呢,子进程无休眠的一直写入数据,但父进程每隔五秒做一次数据读取,是什么现象呢??
-
读写端正常,管道如果被写满,写端阻塞
cppvoid Write(int wfd) { ...... while (1) { snprintf(buffer, sizeof(buffer), "%s-%d-%d", s.c_str(), self, number++); write(wfd, buffer, strlen(buffer)); cout << number << "\n"; } } void Reader(int rfd) { char buffer[M]; while(1) { sleep(5); ssize_t n = read(rfd, buffer, sizeof(buffer)); ...... // TODO } }
现象:因为读端每读一次就要休眠 5 秒,写端一直在往管道中写入数据,所以管道的一下子就写满了,管道被写满之后写端也就阻塞等待着读端读取数据,而不会一直覆盖式的继续写入,因此这个现象也告诉我们,管道是有大小的。
我们还看到了,写端写入的数据是一行一行的字符串数据,但是读端读取到的是一大坨的字符串,这是因为不管写端是一次性、还是分批的将数据写入管道,读端都会一次性把管道的数据读出来(如果用户缓冲区足够大的前提下)。在父进程的视角,它无法知道子进程写入的是一行一行的字符串数据,在父进程的视角上只有一个一个的字符,因为管道是面向字节流的。
而对于读端从管道中读取出来的数据是乱的,这个问题是需要用户层去解决的,所以这就需要用户层在通信时制定一些通信协议,比如规定一次写入的字符串数据的大小,然后读端读取数据时也得按这个大小为单位进行读取。
-
读端正常读,写端关闭,读端读到文件结尾,读端不阻塞。(即子进程先退出,父进程继续读数据)
cppvoid Write(int wfd) { string s = "I am a child process!"; int number = 0; while (1) { sleep(1); char ch = 'c'; write(wfd, s.c_str(), s.size()); number++; cout << "写入数据:" << s.c_str() << "\n"; if(number >= 5) break; // 子进程退出 } } void Reader(int rfd) { char buffer[M]; while(1) { ssize_t n = read(rfd, buffer, sizeof(buffer)); if(n > 0) { buffer[n] = '\0'; cout << "father process(" << getpid() << ") get the message: " << buffer << endl; } cout << "读取的实际字符:" << n << '\n'; }
现象:子进程正常写入时,父进程正常读取,当子进程退出后,父进程并且没有阻塞,而是一直继续读取,只不过实际读取的字节数为 0(即 read 的返回值,读取成功时返回实际读取字节个数,0 表示读到文件结尾)。
所以当读端正常读取、写端关闭的情况,读端把数据读完之后,就不再继续读了,而写端关闭后,读端不会处于阻塞状态!
所以当这种情况,正确的代码处理应该是,当父进程读取到文件结尾后,跳出循环,回收子进程,最后自己退出。
cppvoid Reader(int rfd) { char buffer[M]; while(1) { ssize_t n = read(rfd, buffer, sizeof(buffer)); if(n > 0) // TODO else if(n == 0) break; // 写端关闭后,读端读到文件尾后,不再继续读取 else break; } }
-
写端正常,读端关闭
在做这种情况的实验之前,我们可以简单预测一下它的结果。读端都关闭了,还有必要继续写吗??写给谁看呢??所以读端如果关闭了,写端有非常大概率直接就不写了,而写端存在的意义就是写数据,你都不写了,你这个进程还留着干嘛,浪费我操作系统的资源吗??诸如闲置的进程被操作系统挂起、父子进程的写时拷贝、还有缺页中断式的惰性加载,这些都足以证明操作系统是不会做低效、浪费任何资源等类似的工作的。如果做了,那就是操作系统有 bug!
所以如果读端被关闭,写端即没有存在的意义,一场再浩大的演唱会,失去了观众,也就失去了存在的意义。没有进程读 取,写端还继续写入数据,那么也一样要占用调度资源、cpu 资源、内存资源,因此操作系统就会通过信号杀掉正在写入的这种进程。(为什么是通过信号?进程的创建、终止 讲进程终止时我们曾谈过,任何形式使进程异常退出的本质都是进程收到了某种信号!)
接下来,我们要验证上述猜想,并且顺便把让子进程终止的信号打印出来。
cppint main() { // TODO ..... pid_t id = fork(); if (id < 0) return 1; else if (id == 0) { // TODO ..... Write(pipefd[1]); // 子进程写 exit(0); } Reader(pipefd[0]); // 父进程读5s close(pipefd[0]); // 关闭读端 // TODO ..... return 0; }
现象:前面 5 秒钟,子进程正常写、父进程正常读,5秒后,父进程直接把读端的文件描述符关了,不读了!子进程立马被操作系统杀掉,变为僵尸状态,等待父进程回收。
3.2 管道的大小
bash
# 可查看当前系统内核允许一个进程打开的最大文件数、还有管道的容量大小等信息。
ulimit -a
根据系统查看的信息,512 bytes * 8 = 4kb,即创建出来的管道的大小就是 4kb,但其实通过代码层面去验证,发现是这个大小是有的问题的。
cpp
int pipefd[N];
int n = pipe(pipefd);
int number = 0;
char ch = 'c';
write(pipefd[1], &ch, 1);
number++;
cout << number << "\n";
每次向管道写入一个字符,然后计数统计的结果一共是向管道写入了 65536 bytes 的数据,65536 bytes = 64 kb,所以这是系统信息的错误吗??
其实也不是系统提供的信息有错,只不过是内核版本更新时做了优化,man 7 pipe 查看管道的容量说明
在 linux 内核 2.6.11 之前,管道的大小是与系统的页大小一样的(即 4kb),但在 2.6.11 之后的版本,管道的大小为 65536 bytes,即我们代码层面验证的结果。
同时,管道还规定数据的原子性,即一次读取的数据不能小于 PIPE_BUF(即4kb)。
什么是原子性?假设子进程要向管道写入字符串 "hello world",并且规定以 "hello world" 这一个字符串的大小为一个单位,因此当子进程写入数据时,刚写完 hello,world 这个单词还没写,父进程看到管道中有数据了,也不能进行读取,因为规定了一次读取就要读 "hello world" 大小这么多的数据,这就是管道的原子性。当写入数据大小没超过 4kb 时,即便另一方进程看到管道中有数据了,也无法进行读取,要么等写入端停止写入,要么等数据超过 4kb 时才能进行读取。
所以 ulimit -a
查看到的管道的大小,可以理解为一次读取的最小单位,即 PIPE_BUF 的大小。
3.3 总结管道的五个特征
- 具有血缘关系的进程才能进行进程间通信
- 管道只能单向通信
- 父子进程是会进程协同的,同步与互斥的,以此来达到保护管道文件的数据安全
- 管道是面向字节流的
- 管道是基于文件的,而文件的生命周期是随进程的!(当父子进程进行管道通信时,一方都只保留一端,所以对于 struct file w 或者 struct file r ,在文件对象内部的引用计数都为1,因此当正在通信的进程全部退出后,对应的 struct file 一端的引用计数就会变为 0,引用计数为 0 时操作系统就会释放文件对象及其数据,因此管道文件也就被关闭了)
4. 管道的应用场景
4.1 命令行中的管道
这篇文章讲的基于文件的通信方式,即匿名管道,在我们命令行中就经常使用到,比如
bash
cat test.txt | head -10 | tail -5
上面的这条指令,在命令行解释中,会先创建出两个管道文件;而每一部分的指令,执行起来都会创建为一个进程,并且它们都是父进程 bash 的子进程,具有血缘关系,因此可以进行管道通信。该指令在通信时的本质,就是将文件的标准输出做重定向到管道的写端,接着由另一个进程从管道中读取数据,也即从标准输入重定向到管道的读端,以此类推。而诸如这类命令行指令,使用的就是 匿名管道!
4.2 进程池中的管道
为了方便大家理解什么是进程池,我以内存池为例,我们平时在语言中向操作系统 malloc / new 申请的内存,其实都是在所谓的内存池中拿的内存,并不是直接从操作系统那拿的。如果频繁的向操作系统申请内存,会导致程序的运行效率和响应速度不高(操作系统可是很忙的,不仅要管理上百进程、文件等内核结构,还要管理硬件),而不管是 malloc 还是 new,底层一定都是调用的系统调用,只要是系统调用,就有性能消耗。有了内存池,可以一次性向操作系统申请多一些的内存,后续当用户 malloc/new 时,直接从内存池中拿就行了,不用了再释放回去,释放回去后也还是回到内存池,而不会回到操作系统中,这样也可以循环利用这些内存,大大减少了向操作系统申请内存的频率,程序的响应速度也就提高了。
所以,进程池也是类似于内存池的一种理念,池化技术都是为了提高程序响应速度、减少访问操作系统的次数。而创建子进程的最终目的都是为了帮助用户执行任务、解决需求的。如果没有进程池,当来一个任务时就 fork() 一次,任务执行完毕后再 waitpid 回收子进程,后续来任务再创建,这样的话,程序的响应速度就不高(频繁的调用系统调用)。因此进程池的本质也就是提前创建好一批子进程,等待用户调度分配任务即可。
所以管道在进程池中的应用就在于,因为创建了一批子进程,因此父进程需要管理控制子进程,那么就需要与子进程进行通信,而通信的本质就是进程双方能够看到一块共同的 "资源",即管道文件。建立通信后,父进程可以通过发送 "信号" 等行为向子进程分配任务。
因此能够确定的是,每一个子进程与父进程之间都要创建一个管理来建立父子进程间的通信信道,然后父进程对管道做写入,子进程从管道中读取父进程分配的任务等信息。而当父进程没有向管道中写入数据时,子进程就阻塞在管道的读端,等待父进程分配任务。并且,为了防止上述案例 "父进程毫无协议的写数据,子进程也毫无规律的读取,读取到的数据就乱成一团的问题",我们在编码实现上,人为约定好一次读写,都以 4bytes 为单位,不可多读,也不可多写。
关于管道的一个应用场景 ----- 进程池的代码案例 管道在进程池中的应用案例。
如果感觉该篇文章给你带来了收获,可以 点赞👍 + 收藏⭐️ + 关注➕ 支持一下!
感谢各位观看!