读书笔记:《Unix网络编程(第2版)》卷2:进程间通信

这是《UNIX网络编程 卷2:进程间通信》(W.Richard Stevens)的读书笔记以及批注。

参考资料

  • Linux函数速查手册
  • 书籍源代码下载
  • Linux man pages online

第二部分 消息传递

第4章 管道和FIFO

4.3 管道

Page.33

pipe函数返回两个 文件描述符,前者用于读管道,后者用于写管道

这意味着,可以使用writereadopen来像操作文件一样读写管道。

父进程创建一个管道,然后 fork出子进程,接着父进程关闭这个管道的读出端,子进程关闭同一管道写出端,形成了从父进程流向子进程的单向管道。

fork出的子进程显然和父进程具有亲缘关系,所以子进程会持有和父进程完全相同的fd文件描述符副本(作者在Page.25倒数第三段中写到:“……有内核维护的打开文件的文件描述符……只在单个进程内有意义……假如说文件描述符4……对于可能在另一个与本进程无亲缘关系的进程中打开在文件描述符4上的文件而言根本没有意义”)。

如果父子进程均不关闭管道任何一端,此时如果从子进程发送消息,同时从父子进程都开始读取,如下所示,会怎样呢?:

#include
#include

#include
#include
#include
#define MAXLEN 15

int main(int argc,char **argv){
    int fd[2];
    pid_t cpid; //child process pid
    ssize_t n; //indeed read-in size
    pipe(fd); //get pipe

    char buff[MAXLEN];

    if((cpid=fork())==0){
        //child process
        write(fd[1],"hello",6);
        printf("\nIn Child Process:Write ends\n");
        
        while((n=read(fd[0],buff,MAXLEN))>0){
            printf("In Child Process:message is %s\n",buff);
        }
        printf("\nIn Child Process:Read ends\n");
        exit(0);
    }else{
        //parent process
        while((n=read(fd[0],buff,MAXLEN))>0){
            printf("In Father Process:message is %s\n",buff);
        }
        printf("\nIn Father Process:Read ends\n");
        waitpid(cpid,NULL,0);
        exit(0);
    }
}
  • #include:pipe,read,write
  • #include#include:waitpid
  • 该程序的父子进程没有正常关闭fd[0]写出端

编译运行上面的程序,结果有如:

In Child Process:Write ends
In Child Process:message is hello

In Child Process:Write ends
In Father Process:message is hello

语句顺序可能相反,但是不出意外的,它们都会在此时阻塞。也就是说,子进程通过write写入fd[1]的数据可能被子进程或父进程的read读入,但是任何一个进程读入后,两个进程同时在下一轮或本轮的read进入了阻塞状态。
对于read函数的说明是:

ssize_t read(int fd,void * buf ,size_t count)
read()会把参数 fd所指的文件传送 count个字节到 buf指针所指的内存中。若参数 count0,则 read()不会有作用并返回 0。返回值为实际读取到的字节数,如果返回 0,表示已到达 文件尾或是 无可读取的数据,此外,文件 读写位置会随读取到的字节 移动

读取管道的信息时,文件读写指针也会发生移动,所以任何一个进程读完其中的数据后,没有向缓冲区填入新的数据,那么任何试图从空管道中读取数据的进程因为没有数据可读(file pointer indicates this scenario.),都会被阻塞在这里。

值得注意的是,管道与一般文件不同的是,文件总是要有EOF的,这标志着读取过程的结束;而管道因为写入信息具有偶发性,所以,当管道没有数据时是read不能立刻返回的,因为发送方的数据随时可能到达,那么read如何知道读取结束了呢?显然,只有发送进程明确关闭了写入端close(fd[1]),此时读取方才能获得一个EOF
而在这里,因为父子进程是有亲缘关系的进程,所以二者持有的fd符实际上是同一个内存缓冲区域,所以管道的读入读出端口实际上被两个进程引用着。这意味着,就算子进程发送数据出去后立刻关闭写出端口,父进程仍持有写入端的描述符,从而两个进程的read都无法关闭,因为内核会认为父进程随时可能从该写入端写入数据。

为了印证这一点,可以修改代码为:

//child process
while((n=read(fd[0],buff,MAXLEN))>0){
    printf("In Child Process:message is %s\n",buff);
    close(fd[1]);
}
...
//parent process
while((n=read(fd[0],buff,MAXLEN))>0){
    printf("In Father Process:message is %s\n",buff);
    close(fd[1]);
}

无论哪一个进程从管道中读入了数据,它都会立刻关闭自己的写入端。假如子进程发送的消息被子进程读到了,从而关闭了子进程的写入端。那么父进程在执行read时就会被阻塞,因为此时管道的唯一写入端就在父进程手中!对于父进程,这陷入一个悖论:“我必须等待,因为我不知道什么时候会给自己发送消息”。

因此,最佳实践是,让发送方一开始就关闭读入端口,让接收方一开始就开始关闭写出端口。从而保证单向管道只有唯一的读入写出端口。

另外,write操作对管道和FIFO的操作,如果写入数据量小于PIPE_BUF,那么就保证是原子的,否则不保证是原子的,这意味着如果发送方发送大量数据(远远多于PIPE_BUF)后,发送/接收双方同时都开始收,那么可能会造成数据紊乱,因为哪个进程在什么时候开始读取是不定的:

#define MAXLEN 1048576 //修改为大量数据
//child process
        write(fd[1],str,MAXLEN);
        printf("\nIn Child Process:Write ends\n");
        while((n=read(fd[0],buff,MAXLEN))>0){
            printf("In Child Process:message length=%d\n",(int)strlen(buff));
        }
//parent process
        while((n=read(fd[0],buff,MAXLEN))>0){
            printf("In Father Process:message length=%d\n",(int)strlen(buff));
        }

执行结果可能如下:


In Child Process:Write ends
In Child Process:message length=65535
In Father Process:message length=983039
...(blocking)

子进程接收到65536字节、父进程接收到983040字节数据,这是因为strlen函数统计时没有吧'/0'算在其中,它们加和恰好是1048576
作者在Page.36下方写有:“对于管道的read,只要该管道存在一些数据就会马上返回,不必等待到达所请求的字节数”。

更多参考:

  • Why parent process has to close all file descriptors of a pipe before calling wait( )?
  • Linux进程通信(一)——pipe管道

Page.35

对于客户端-服务器模型,使用单向的管道是很难完成数据的交互,因为涉及到收发双方的数据同步问题,极端例子是发送方发出的数据被自己收到了,这样导致管道内无数据可读,此后二者陷入阻塞。
解决这个问题的一个方法是使用两根管道,方向相反,这样,服务器进程一开始在管道A读入端陷入阻塞,等待客户端的数据到来;客户端从管道A发送数据后,开始在管道B的读入端等待服务器,服务器处理完数据后,将反馈信息顺着管道B的发出端口发出,完成一次信息交换。
所以作者的代码中有:

client(pipeB_readin_fd,pipeA_wirteout_fd);
server(pipeA_readin_fd,pipeB_writeout_fd);

4.6 FIFO

Page.41

FIFO的名字只有通过调用 unlink才能从文件系统中删除。

FIFO的创建流程是:

  1. 调用mkfifo创建FIFO,若返回-1,到第2步,否则到第3步;
  2. 若返回值是-1,且errno是EEXIST,则说明同名FIFO已存在,到第3步;否则说明创建失败,进行错误处理;
  3. 现在可以使用open来调用打开FIFO进行文件写入
  4. 像关闭文件一样使用close来关闭FIFO
  5. 使用unlink来将FIFO从文件系统中移除

unlink函数的定义是:

#include 
int unlink(const char *pathname);

Delete a name and possibly the file it refers to.unlink() deletes a name from the filesystem.
If that name was the last link to a file and no processes have the file open, the file is deleted and the space it was using is made available for reuse.
If the name was the last link to a file but any processes still have the file open, the file will remain in existence until the last file descriptor referring to it is closed.
If the name referred to a symbolic link, the link is removed.
▲ If the name referred to a socket, FIFO, or device, the name for it is removed but processes which have the object open may continue to use it.

Page.42

在这篇笔记中使用父子进程用FIFO模拟了客户端-服务器模型用以交互信息。
特别注意open一个FIFO时,如果是以只读方式打开,那么如果这个FIFO还没有以只写方式打开过得话就要陷入阻塞。如果发送方发送完消息不close掉FIFO,那么读端将在readFIFO时陷入阻塞,假如写端此时也在等待读端反馈,那么很有可能就会陷入死锁。

4.7 管道和FIFO的额外属性

Page.45

关于图4-21,第一列“当前操作”表示现在准备要进行的操作,第二列“管道或FIFO现有打开操作”表示在某个进程中已经完成的操作。例如:

  • 当前操作:open FIFO只读
  • 管道或FIFO现有打开操作:FIFO不是打开来写
  • 返回:阻塞:阻塞到FIFO打开来写为止

这表示,某FIFO已经在某个进程中打开,且没有设置O_WRONLY,即没有写端能够对该FIFO进行写操作,那么如果现在对该FIFO执行open操作且使用O_RDONLY表示读,则会陷入阻塞,因为没有写端,自然读不出任何东西。

选项O_NONBLOCK表示非阻塞,加上这个选项后,表示open调用是非阻塞的,如果没有这个选项,则表示open调用是阻塞的。

4.8 单个服务器,多个客户

Page.47

在这种一对多的服务器-客户端中,代码实现的是迭代服务器,即一次只服务于一个客户,其他客户需要等待。一个误区在于,可能认为一个客户端写入/tmp/fifo.serv的FIFO后,哪怕FIFO中还有空域区域,其他客户端就在write函数中阻塞,不会写入东西,一直等待服务器处理完该客户端消息后才会开始写入。
事实上注意到第16行读取的函数是Readline,也就是说,多个客户端可以并发地向同一个FIFO中写入数据,只有当FIFO满了以后其他客户端才会在write中阻塞,而服务器完全是根据一行一行的读取内容,因为一行代表了一个客户端请求。因为在例子中的假设中,客户端请求总是小于PIPE_BUF,所以写入操作总是原子性的。

另外,在这里虽然服务器对服务器FIFO只进行读操作(写操作由客户端进行),但是仍然持有一个对该FIFO的写端(dummyfd),从而,就算没有任何一个客户端存在,因为存在该FIFO的写端,服务器得以在read中阻塞,等待着消息的到达,而不是遇到EOF而关闭FIFO。

Page.48

对客户端FIFO的unlink由客户端完成,服务器端只要close客户端FIFO,就可使得客户端在read中读到EOF而结束。

Page.49

可以在shell中使用命令mkfifo来创建FIFO

在本页有一个命令行例子:

echo "$Pid message" > /tmp/fifo.serv
(间隔相当长的时间后)
cat < /tmp/fifo.$Pid
....(服务器应答消息)

两个命令之间可以间隔相当长的时间,但是仍然能够获得服务器消息。错误的理解是:服务器读取/tmp/fifo.serv内的请求后作处理,将处理结果写入/tmp/fifo.$PidFIFO中,然后服务器进程就关闭了。从而客户端进程可以在任何时候从/tmp/fifo.$Pid读取。

实际是,服务器读取客户端请求后处理,但是要将处理结果写入客户端的/tmp/fifo.$PidFIFO之前必须要进行open,然而在此时客户端尚未对FIFO进行只读打开,没有读端,所以服务器在open中阻塞,因此,在cat < /tmp/fifo.$Pid命令之前的时间中,服务器进程始终在阻塞,没有结束。直到客户端打开FIFO时,服务器才写入,然后客户端才能读取。

如果管道、FIFO全部被close(没有读端也没有写端,即文中的“最终close”),那么管道、FIFO的数据都被丢弃。

4.10 字节流与消息

4.11 管道和FIFO限制

Page.55

系统对 管道和FIFO 的唯一限制 是:

  • OPEN_MAX:一个进程在任意时刻打开的最大描述符数
  • PIPE_BUF:可原子的写入任何一个 管道和FIFO 的最大数据量。
  • OPEN_MAX可以通过sysconf函数来查询,查询的宏是_SC_OPEN_MAX。在这个网站可以查看到该函数的详细情况。
  • PIPE_BUF可以通过pathconf函数来查询,查询的宏是_PC_PIPE_BUF。在这个网站可以查看到该函数的详细情况。
pathconf函数

pathconf函数的接口定义是:

#include 
long fpathconf(int fd, int name);
long pathconf(const char *path, int name);

其作用是获得文件名path/文件描述符fd的名为name的配置的值。
这些值在unistd.h头文件中也定义了相关的宏可以获取,但是这些宏只是规定了这些值的最小值,是静态不可变的。如果应用想要获取实时的值(这些值可能发生变动),那么就要调用这两个函数。
其中name可以指定为已经预定好的宏名,例如:

name = _PC_PIPE_BUF: 可以 原子的写到FIFO管道中的最大字节数。对于 fpathconffd参数要是管道或FIFO的描述符;而对于 pathconfpath是一个FIFO路径或者是目录名,如果是目录名,那么返回的值就是创建在该目录下的FIFOs的最大字节数。

可以看出,Posix认为PIPE_BUF是一个pathname variable,它的值可能会随着指定的路径名而发生变化。
这里值得注意的是pathconf函数的返回值,帮助手册写的是:

pathconf函数的返回值是如下情况中的一种:

  • 如果出现错误,那么返回-1,并且使用errno全局变量来指示错误原因。
  • 如果name是关于限制最大/最小这方面性质的配置名,且其限制值是不明确的(indeterminate),那么返回-1,并且errno不变为了将这种情况和上一种区分开来,请先设置errno变量为0,调用完函数后再检查当-1返回时errno是否是非零值即可)。
  • 如果name参数是受支持的配置选项名,那么返回一个正值,否则返回-1。
  • 否则,其选项或限制的值将被返回。这个(动态返回的)值比与之相关的定义在头文件unistd.hlimits.h中的用于描述该应用的(静态)值更宽泛(not be more restrictive)

注意到前两点,就可以明白为什么作者在wrapunix.c中将包裹pathconf的包裹函数定义为

//in wrapunix.c by W.Richard Stevens
long Pathconf(const char *pathname, int name)
{
    long    val;

    errno = 0;        /* in case pathconf() does not change this */
    if ( (val = pathconf(pathname, name)) == -1) {
        if (errno != 0)
            err_sys("pathconf error");
        else
            err_sys("pathconf: %d not defined", name);
    }
    return(val);
}

同时还注意到了errno这个变量。errno是记录系统的最后一次错误代码。代码是一个int型的值,在errno.h头文件中定义。在博客《Linux errno详解》一文中,作者写道:

Linux中 系统调用的错误都存储于 errno中, errno由操作 系统维护,存储 就近发生的错误,即下一次的错误码会 覆盖掉上一次的错误。只有当 系统调用 或者 调用lib 函数时出错, 才会置位errno。

可以使用定义在string.h中的strerror函数来根据errno获得错误说明字符串,或是通过定义在stdio.h头文件中的perror函数来把系统调用错误信息字符串发送到标准输出。这篇帮助文档给出了可能的错误代码。

sysconf函数

sysconf函数接口是

#include 
long sysconf(int name);

是用来在运行时获得配置信息。在这篇帮助手册中详细说明了sysconf函数。如果name _SC_OPEN_MAX,那么它表示一个进程最多能打开的文件数。其返回情况与上面的pathconf是完全一致的。
所以作者定义了类似了包裹函数

long Sysconf(int name)
{
    long    val;
    errno = 0;        /* in case sysconf() does not change this */
    if ( (val = sysconf(name)) == -1) {
        if (errno != 0)
            err_sys("sysconf error");
        else
            err_sys("sysconf: %d not defined", name);
    }
    return(val);
}

使用如下语句即可完成查询:

printf("PIPE_BUF=%ld, OPEN_MAX=%ld\n", Pathconf(argv[1],_PC_PIPE_BUF),Sysconf(_SC_OPEN_MAX));

第5章 Posix消息队列

5.2 mq_openmq_closemq_unlink函数

Page.60

unlinkclose的区别:笔记:磁盘分区、文件系统、链接。如果调用mq_unlink时,那么指定的队列就会被从系统删除,但是注意,如果此时其链接计数(或引用计数)不为0,说明仍有进程在使用它,它就是“悬空的”,这就意味着,它已经被系统除名,已经不能通过它的名字找到它,但是在正在使用它的进程中还是能够正常使用(因为持有它的描述符,可以理解为指针)。当最后一个mq_close关闭它后,它的引用计数就变成了0,说明它已经完全不能被找到,这个时候队列就会被析构,完全消失。

getopt函数的解释参考:使用 getopt() 进行命令行处理。书中实例代码中使用这个函数来处理命令行输入的命令字符串并获得相关设置,使用while是不断处理命令字符串中的选项,当该命令字符串被解析完,while就会退出(getopt返回-1)。也就是说,它只能一次处理一个命令字符串。

你可能感兴趣的:(unix网络编程,进程间通信)