UNP卷二随笔
一 概述
本书中提到的进程间通信方式有两个分支,posix和system V,如今的linux系统大多都支持这两个分支。
posix和system V提供的进程间通信接口有着明显的区别,第二章和第三章说明了这些。
进程间通信的方式有三种:
①文件系统。通过read、write等函数,穿过内核,于文件系统上实现进程之间的共享,当然这需要某种同步操作。这种类型的持久性基于文件何时被删除。
②内核。在内核上建立的某个<进程间通讯的对象>,不同进程间通过调用一组相关函数,通过这个对象在进程间传递信息。其持久性不会随着进程结束被删除,只有显示调用某个删除操作,或者内核重启,这种对象才会消失。例如posix消息队列(今天刚看到这)。
③进程。这类对象由进程创建,随着进程消失而消失,诸如套接字、匿名管道,有名管道等。
持续性随内核的IPC(进程间通信,后续都简称IPC)的有:
posix消息队列
posix有名信号量
posix共享内存
system V的消息队列
system V信号量
system V共享内存。
其余的IPC方式的持续性都是随进程。
还提到关于fork、exec、exit等函数对各种IPC对象的影响,和书中想法大致一样,既然要fork或者exec,那么就应该在程序一开始,而不是在制造了一堆东西后再fork让他们变得混乱。
再次提到了errno,它使用线程特定数据实现,每个线程都有独立的errno,errno没有值为0的错误。
二 关于posix IPC对象创建时使用的一些参数值
如下这下参数是posix IPC使用的,诸如posix消息队列、posix信号量、posix共享内存等这些对象创建时应该传递的值。
不管是消息队列、信号量,还是共享内存,只要创建或打开时指定了O_CREAT,比如就需要指定其后续的如posix信号量的mode_t类型的参数。(详见posix对应章节)
这里只是说明这些参数有哪些值,至于如何使用见后。
oflag参数
mq_open列 对应 创建posix消息队列时可使用的参数
sem_open列 对应 创建posix信号量时可使用的参数
shm_open列 对应 创建posix共享内存时可使用的参数
mode_t类型的参数
posix创建时都需要一个名字,或者说路径,但是其会可移植性问题,该自定义函数来解决。
#define POSIX_IPC_PREFIX "/"
char *px_ipc_name(const char *name)
{
char *dir, *dst, *slash;
if ( (dst = malloc(PATH_MAX)) == NULL)
return(NULL);
if ( (dir = getenv("PX_IPC_NAME")) == NULL)
{
#ifdef POSIX_IPC_PREFIX
dir = POSIX_IPC_PREFIX; /* from "config.h" */
#else
dir = "/tmp/"; /* default */
#endif
}
slash = (dir[strlen(dir) - 1] == '/') ? "" : "/";
snprintf(dst, PATH_MAX, "%s%s%s", dir, slash, name);
return(dst); /* caller can free() this pointer */
}
三 关于system V IPC对象创建时使用的一些参数值
system V与posix的IPC创建不同。
posix IPC提供的创建接口,oflag参数表示如何创建或者打开,随后的mode_t参数指明了对象的rwx权限。
system V IPC提供的创建接口,将如何创建、打开、rwx权限等全都聚合到了oflag参数。(详见system V对应章节)
这里只是说明这些参数有哪些值,至于如何使用见后。
oflag参数
system V并没有提供读写权限,因为只要打开system V IPC对象的所属进程有对应的rwx权限,他就会获得对应的权限。这里只有IPC_CREAT、IPC_EXCL和对应的rwx权限组合。
第一张图和posix的一样,第二张图则说明了不同system V IPC对象的对应权限值,这些值都在oflag参数中通过按位或指定。
每个system V IPC对象都会有一个与之相关的ipc_perm结构。
struct ipc_perm
{
uid_t uid; // 拥有者用户id
gid_t gid; // 拥有者组id
uid cuid; // 创建用户的id
gid_t cgid; // 创建用户的组id
mode_t mode; // 读写权限
ulong_t seq; // 槽位使用序列号?
key_t key;
};
四 管道和FIFO
1.管道
关于管道,其只能用于有父子关系的进程之间,且是单向数据流。使用int pipe(int fd[2])
函数,通过输出参数返回一对文件描述符,fd[0]用于读,fd[1]用于写,书中使用几个例子描述了其如何使用,父进程建立两对这样的管道,fork之后,父子进程分别关闭一对的读描述符,关闭另一对的写描述符,以形成一个全双工通道。细节在此不过多描述,主要聚焦于FIFO。
FILE * popen(const char*command, const char *type);
int plose(FILE * stream);
以上两个函数是关于管道的另一个应用,前者由shell程序执行其第一参数的命令行,创建另外一个进程,并在调用进程和被创建的进程间建立一条管道,type参数为’r’代表调用进程读入command的输出, 为’w’代表调用进程写到command的输入。
2.FIFO
FIFO又称有名管道。既有名,那么他是一个存在于系统上某个路径上的文件。
使用FIFO(代码见后):
①一个进程中调用mkfifo
函数来创建一个文件,并在当前进程中使用open
函数打开它。
②另一个进程中也使用open()
函数打开这个这个文件,从而建立一个有名管道。
③结束通信后,两个进程都调用close
关闭使用open
打开的描述符。虽然只要管道的一边被close
,另一边的read
也会相应的返回0表示文件结束,不过感觉显示关闭总会有一种仪式感,
④其中一个进程调用unlink
来删除使用mkfifo
创建的文件。
例子以及其他的一些补充:
int mkfifo(const char *pathname, mode_t mode);
mkfifo
默认指定了O_CREAT | EXCL,他要么创建一个新的FIFO,要么返回EEXIST错误(errno)。
如果一个进程打开FIFO来读,却没有进程打开FIFO写,读操作被阻塞,当然也可以对其设置非阻塞。
FIFO也是单向的,要建立全双工通道,那么就需要建立两个FIFO。
OPEN_MAX:管道和FIFO也是描述符,该常量是一个进程同时可打开的最大描述符数
PIPE_BUF:书中原话“可原子的写入管道或FIFO的最大数据量”,但是字面来看,却更像缓冲区大小,当然他不是。
如果请求读出的数据量小于FIFO中的数据量,那么只返回可用数据。
如果写入字节数小于PIPE_BUF,保证是原子写入,但是超过就不能保证了。
如果是非阻塞:
写入字节数小于PIPE_BUF
有足够空间,写入。
没有足够空间,errno设置为EAGAIN。
写入字节数大于PIPE_BUF
有多少空间写多少空间,写入的字数作为write返回值。
一字节空间也没有,即FIFO满,errno设置为EAGAIN。
书中还描述了一个例子,该例子是进程A建立了一个众所周知的FIFO路径,进程A读他,其他任意进程都可以通过open
来打开它写入信息(同时也说明了可以多个进程向一个FIFO写),这里的重点不是例子本身,而是其中的一个小细节:
{
进程A对这个FIFO读打开一次,产生一个描述,写打开一次,产生另一个写描述符,虽然这个写描述符从来没有被使用,但是他有存在的价值。
假设A已存在,但没有打开这个写描述符,当另外一个进程写打开这个FIFO然后关闭,会导致A的读描述符返回0,意味着文件结束,那么A中的读描述符就会失效。而A中写描述符的存在避免了这样的行为(返回0),或许背后是一个类似引用计数的行为,只要写计数为0,就返回文件结束了(0)。
}
最后还有在FIFO上自定义协议头,就像TCP的应用层那样,这种通用性技巧自然不必多说。
#define FIFO1 "/tmp/fifo.1"
#define FIFO2 "/tmp/fifo.2"
#define FIFE_MODE (S_IRUSR | S_IWUSR | S_IRGRP | S_IROTH ) // 用户读、用户写、组读、其他读
进程A的核心代码
if( (mkfifo(FIFO1, FILE_MODE) < 0) && (errno != EEXIST) )
{
printf("如果mkfifo出错了,但errno不是EEXIST,那么mkfifo就不是因为文件已存在而创建失败的");
exit();
}
if( (mkfifo(FIFO2, FILE_MODE) < 0) && (errno != EEXIST) )
{
printf("出错同上,但是如果这里失败了,还要注意删除FIFO1创建的文件。");
unlink(FIFO1);
exit();
}
int readfd = open(FIFO1, O_RDONLY, 0);
int writefd = open(FIFO2, O_WRONLY, 0);
...
close(readfd);
close(writefd);
...
unlink(FOFO1); // 遵循谁创建谁删除的原则
unlink(FOFO2);
进程B的核心代码
int writefd = open(FIFO1, O_WRONLY, 0); // 注意这里对FIFO1是写,而进程A对FIFO1是读。
int readfd = open(FIFO2, O_RDONLY, 0);
...
close(readfd);
close(writefd);
五 posix消息队列
本章首先介绍了如何使用posix消息队列,接着介绍了如何使用信号的方式来实现对消息队列的异步通知,还讲述了实时信号以及顺带复习了apue第十章可靠信号模型的实现,最后给出了一个消息队列的实现代码。
1.posix消息队列的使用
MQ_OPEN_MAX 一个进程能同时打开的消息队列数量
MQ_PRIO_MAX 消息最大优先级值
成功返回消息队列描述符,失败返回-1
mqd_t mq_open(const char * name, int oflag, mode_t mode, struct mq_attr * attr);
name: 是某个要打开或者创建的路径,如/temp.1234
oflag: 是O_RDONLY、O_WRONLY、O_RDWR三个之一,再按位或O_CREAT、O_EXECL、O_NONBLOCK,详见第二章
mode: 详见第二章
attr: 详见后mq_getattr和mq_setattr函数
mode 和 attr是可选的,不是为空,而是可以mq_open(‘路径名’, O_RDONLY | NONBLOCK)
只要指定了O_CREAT,就需要指定后续的mode和attr参数
mq_open函数既能创建一个消息队列对象,也能打开一个,还能创建并打开
成功返回0,失败返回-1
int mq_close(mqd_t mqdes);
mq_open打开了一个对象,自然也要关闭它
成功返回0,失败返回-1
int mq_unlink(const char * name);
根据路径名,从内核上删除mq_open创建的IPC对象
消息队列的创建,随着mq_open的调用增加引用计数,随着mq_close减少引用次数
如果在引用计数为0之前调用unlink,他会等到计数变为0后删除该IPC
struct mq_attr
{
long mq_flags; // 队列标志,这里主要是非阻塞
long mq_maxmsg; // 队列允许的最大消息条数
long mq_msgsize; // 单条消息的最大长度
long mq_curmsgs; // 队列内当前的消息数量
}
成功返回0,失败返回-1
int mq_getattr(mqd_t mqdes, struct mq_attr *attr);
int mq_setattr(mqd_t mqdes, const struct mq_attr *attr, struct mq_attr *oattr);
get是获取消息队列的信息,set就是设置了
set中,旧的信息会在oattr中返回
另外,set应该只用来被设置或清除非阻塞标志,因为mq_msgsize和mq_maxmsg只应该在被创建时设置
mq_curmsgs是当前数量,自然不必多说
成功返回0,失败返回-1
int mq_send(mqd_t mqdes, const char * ptr, size_t len, unsigned prio);
成功返回消息中字节数,失败返回-1
ssize_t mq_receive(mqd_t mqdes, char * ptr, size_t len, unsigned prio);
这两个函数分别从某个队列中放入或取走一条消息
前三个参数指明了哪个消息队列、消息本身、消息长度、缓冲区长度
第四个参数是优先级,MQ_PRIO_MAX见本章开头
mq_receive总是返回优先级最高的消息,同等优先级按先进先出。
2.使用mq_notify以及信号实现消息通知
成功返回0,失败返回-1
int mq_notify(msq_t mqdes, const struct sigevent * notification);
如果notification非空,那么表示当前进程希望接收队列的通知
如果notification空,那么表示当前进程不希望(取消)接收队列的通知
同一时刻只有一个进程能被注册为接收通知的那个
只有在 (队列由空变为非空 && 没有任何mq_receive的调用) 通知才会发出
只要有一个进程在调用mq_receive,他的优先级就是最高的
mq_notify的注册效果只有一次,接收一次通知后必须再次调用注册
struct sigevent
{
int sigev_notify; // 可以为{SIGEV_NONE、SIGEV_SIGNAL、SIGEV_THREAD}
int sigev_signo; // 值应该为某个具体信号值,如:SIGPIPE、SIGUSR1
union sigval sigev_value; // 后三个参数是通过启动一条线程的方式来处理通知,
// sigev_notify为SIGEV_THREAD时使用,见后例子。
void (*sigev_notify_function)(union sigval);
pthread_attr_t *sigev_notify_attributes;
};
union sigval
{
int sival_int;
void *sival_ptr;
};
例子:
信号方式的例子-----------------
int n;
int signal;
sigset_t newmask;
struct sigevent sigev;
mqd_t mqd = mq_open("路径名", O_RDONLY | NONBLOCK);
sigempty(&newmask); // 信号集置空
sigaddset(&newmask,SIGUSR1); // 为信号集添加GIGUSR1信号,该信号是系统提供给用户自由使用的
sigprocmask(SIG_BLOCK, &newmask, NULL); // 阻塞这个信号
sigev.sigev_notify = SIGEV_SIGNAL; // 填写这个结构
sigev.sigev_signo = SIGUSR1; // 当队列由空变非空发出SIGUSR1信号
mq_notify(mqd, &sigev); // 注册通知
for(;;)
{
sigwait(&newmask, &signo); // 等待SIGUR1信号出现
if(signo == SIGUR1) // 如果出现了,那么signo == SIGUR1
{
mq_notify(mqd, &sigev); // 再次注册,收到通知发出SIGUSR1信号
while( 0 <= (n = mq_receive(mqd, 某个缓冲区buff, 缓冲区的长度, NULL)) )
printf("%d %s", n, buff);
if(errno != EAGAIN) // while一直读,知道mq_receive返回<=0的值
exit(); // errno是EAGAIN代表无消息可读,但如果不是EAGAIN,就说明发生了其他错误
}
}
线程方式的例子-----------------
看书中的列子,应该是收到通知的时候就启动一次这个线程
在这个线程中,又一次使用mq_notify注册通知,而mq_notify的参数中又一次包含了《收到通知时启动这个线程》
struct sigevent sigev; // 全局变量
...
{
sigev.sigev_notify = SIGEV_THREAD; // 注意是SIGEV_THREAD
sigev.sigev_value.sival_ptr = NULL; // 成员sigev_value作为sigev_notify_function 的参数
sigev.sigev_notify_function = notify_thread; // 启动哪个函数作为线程
sigev.sigev_notify_attributes = NULL; // 线程属性相关设置
mq_notify(mqd, &sigev); // 注册接收通知,收到通知启动线程函数处理
}
void notify_thread(union sigval arg) // 该函数由用户自定义
{
int n;
char * buff[mq_attr.mq_msgsize]; // mq_attr.mq_msgsize可通过mq_getattr获得
mq_notify(mqd, &sigev); // 再一次注册,收到通知时再启动这个线程
while( 0 <= (n = mq_receive(mqd, buff, 缓冲区的长度, NULL)) )
printf("%d %s", n, buff);
if(errno != EAGAIN) // while一直读,知道mq_receive返回<=0的值
exit(); // errno是EAGAIN代表无消息可读,但如果不是EAGAIN,就说明发生了其他错误
pthread_exit(NULL);
}
3.关于unix信号模型的小结
看这里的时候一度迷惑了很久,又把apue捡起来看了看信号那章。
总结:
直接使用系统提供的signal函数是不安全的,他可能出现的问题:
signal函数可对某个信号设置一个《信号处理程序》,apue原话<信号发生之后 到 调用信号处理程序之间有一个时间窗口,在此时间段内,可能发生另一次中断信号,第二次信号会执行默认动作>,大概就是短时间内连续两次产生了同一信号,第一个信号还没被处理第二个就发生了,第二次信号会调用默认行为而不是信号处理程序,但大多数信号的默认动作是结束程序。
解决方式:
①直接屏蔽阻塞这个信号,当我明确知道这个信号会发生,但又不会发生严重后果,那么就解决提出问题的人
②使用sigaction函数,该函数也会为信号设置一个信号处理程序,但在调用信号处理程序之前,他会先屏蔽阻塞这个信号,然后再调用信号处理程序,处理完之后再解除阻塞,这样如果在前面提到的时间窗口中再次产生同一信号,它就会被阻塞,解除阻塞后,这个信号被递送到进程,接着就可以回到本段开头,进行又一次处理。
③设置SIG_IGN。这种方式只是猜测,因为我不确定忽略信号这种行为 是否被定义为一个 信号处理程序,只是随便写写,前两种也足够了。
4.实时信号
所以实时信号的值都位于SIGRTMIN和SIGRTMAX之间。
实时信号是排队的,产生多次,也会递交多次,并且先进先出。
SIGRTMIN和SIFRTMAX之间的信号,SIGRTMIN会比SIGRTMIN+1的信号优先递交,信号值小的优先。
要使用实时信号,要在某个接收信号的进程的sigaction调用中指定SA_SIGINFO标志。
实时信号的使用也基于sigaction();
struct sigaction act, oact; // 关于sigaction结构的定义在apue10.14节
act.sa_sigaction = func; // func是那个信号发生时被调用的处理程序
act.sa_flags = SA_SIGINFO; // 使用实时信号必须设置这个
act.sa_mask = mask; // mask是一个sigset_t类型
// 可用sigemptyset、sigaddset那一组函数来把某个信号添加到到这个掩码里
// 当sigaction函数所标识的信号发生时,屏蔽阻塞这个掩码里的所有信号
sigaction(signo, &act, &oact); // 调用sigaction将信号和对应的行为(act)绑定。
--------------------------------------------------------------------------------------
sigqueue()函数是一个实时信号的函数,它可以对某个进程发出一个信号,并且携带一个sigval作为信息,以下是一个例子
-----
//这三行发送一个实时信号到某个进程,并且携带一个整型
union sigval val;
val.sival_int = 999;
sigqueue(pid, SIGRTMIN+1, val);
// 携带的这个整型如何取?
首先sigaction.sa_sigaction这个成员的类型是void(int signo, siginfo_t * info, void *context);
然后就可以在信号处理程序中使用info->si_value.sival_int取到这个值为999整型
-----
2021年11月3日13:19:40 1,4,5章
六 system V消息队列
system V消息队列也是随内核存在的,也需要经历建立-打开-关闭-删除这个过程。
只要这个IPC对象还活着,那么被塞进去的数据就一直在,直到有人读出他。
每个system V消息队列都会关联一个struct msqid_ds结构。
struct msqid_ds
{
struct ipc_perm msg_perm; // 这个结构体的定义在第三章
struct msg *msg_first; // 队列的第一条消息
struct msg *msg_last; // 队列的最后一条消息
msglen_t msg_cbytes; // 当前队列中的总字数
msgqnum_t msg_qnum; // 当前队列中的信息条数
msglen_t msg_qbytes; // 队列允许的最大字节数
pid_t msg_lspid; // 最后一次调用 msgsnd()的进程id
pid_t msg_lrpid; // 最后一次调用 msgrcv()的进程id
time_t msg_stime; // 最后一次调用 msgsnd()的时间
time_t msg_rtime; // 最后一次调用 msgrcv()的时间
time_t msg_ctime; // 最后一次调用 msgctl()的时间
};
成功返回一个key_y类型,出错返回-1
key_t ftok(const char * pathname, int id);
该函数返回一个key_t类型对象,作为创建消息队列时需要的信息
该函数把pathname参数和id参数以及一些其他的信息组合起来生成一个key_t类型
成功返回非负的标识符,出错返回-1
int msgget(key_t key, int oflag);
返回值:在整个系统范围内是唯一的,用来标识某个消息的唯一id,同时也用作msgsnd msgrcv msgctl的参数
ket: 创建消息队列需要key_t类型的值,书中明确写到使用ftok函数生成是一种方式
IPC_PRIVATE这个常值也可作为key_t参数传递,会保证创建新的、唯一的对象,
IPC_PRIVATE是进程独立的,可以在不同的进程间使用它创建多个IPC
另外key参数,书中的例子有直接用长整数作为参数使用的,但是使用路径名的方式还是比较广泛和符合规则吧
oflag: 读写权限位,被放到msqid_ds.msg_perm.mode中,详见第三章。
成功返回0,出错返回-1
int msgsnd(int msqid, const void * ptr, size_t length, int flag);
msqid: 指明是哪个消息队列
ptr: 是被发送的信息,应该遵循如下模板:
struct msgbuf
{
long mtype; // mtype成员必须大于0,其值意义见后msgrcv函数
char mtext[1]; // 可以理解为一个char*,这个char*指明我们要发送数据的位置
// 可以在mtext的基础上自定义一个结构、协议、亦或是其他
// 而他的长度,要用参数length指明
};
length:尤其要注意,他不是prt整块的长度,而是从mtext处开始的数据的长度,不包括那个mtype
或者说sizeof(msgbuf)-sizeof(long)
flag: 可以为0或IPC_NOWAIT,后者为非阻塞");
非阻塞模式下没有足够空间会返回,errno为EAGIN
阻塞模式下,等待有空间,或者该消息队列被msgctl删除(errno:EIDRM),被打断errn为EINTR
成功返回读入缓冲区的数据长度,出错返回-1
ssize_t msgrcv(int msqid, void * ptr, size_t length, int flag);
msqid: 指明是哪个消息队列
ptr: 接收缓冲区,msgsnd的ptr发的什么,msgrcv就原封不动的取回什么,也包括哪个mtype成员
length:是prt所指缓冲区的大小
flag: 参数可以为IPC_NOWAIT或者MSG_NOERROR
阻塞情况下当然是等待所希望的消息到来
非阻塞情况下是没有所希望的消息就导致errno为ENOMSG,这里《所希望的消息》是关于mtype的
MSG_NOERROR:当要接收的数据比缓冲区大时,设置该位:截断数据,不设置:E2BIG错误
mtype的说明:
type为0: 根据先进先出原则,按序返回每一条消息,这是为什么msgsnd的mtype成员不能为0的原因
type大于0: 返回ptr.mtype为type的第一条消息
type小于0: 返回ptr.mtype值最小的消息,且ptr.mtype应该 小于等于 type取绝对值
成功返回0,出错返回-1
int msgctl(int msqid, int cmd, struct msqid_ds *buff);
msqid: 指明是哪个消息队列
cmd: 提供三个命令:
IPC_RMID: 删除消息队列,队列上的消息被丢弃,buff参数忽略
IPC_SET: 设置以下四个成员:msqid_ds.msg_perm.uid、 msqid_ds.msg_perm.gid、 msqid_ds.msg_perm.mode、 msqid_ds.msg_qbytes。
IPC_STAT: 在buff中返回与消息队列关联的msqid_ds结构。
2021年11月5日21:24:17 6章
七 八 九
第七八章是关于线程的,诸如互斥锁、条件变量、读写锁及其实现等等,关于这些的使用还是使用C++标准库中的比较好。
第九章是关于fcntl的,给文件记录上锁,详解了关于fcntl的使用,但是fcntl本身是不安全的,不论是其劝告性上锁还是强制性上锁,都可能导致数据不一致。
十 posix信号量
posix信号量有两种,一种是posix有名信号量,另一种是posix共享内存信号量。
这两种信号量使用方式或者操作方式(又或者说操作这两种信号量的函数)一样,只是在创建上和其他地方有一些差别。
posix有名信号量:持续性跟随内核,对象本身由系统创建并初始化,返回其指针。
posix共享内存信号量:持续性<至少>跟随进程(实际上跟随共享内存的持续性),对象本身由使用者创建,传递给系统初始化。
关于详细的区别见后。
SEM_NSEMS_MAX 一个进程可同时打开的最大信号量数
SEM_VALUE_MAX 信号量值本身的最大值
----有名信号量的创建及使用
成功返回指向信号量的指针,出错返回SEM_FALIED
sem_t * sem_open(const char * name, int oflag, mode_t mode, unsigned int value);
name: 是在哪个路径上创建或打开
oflag: 如果指定了O_CREAT,那么就需要指定后续的mode和value,另见第二章
oflag参数是默认指定读写模式的,因为对信号量操作是对其本身的加减
mode: 第二章中说明的权限位
value: 是信号量的初值,一般为1,不能超过SEM_VALUE_MAX
成功为0,出错为-1
int sem_close(sem_t * sem);
进程终止时,打开的信号量会自动关闭,或者说引用计数减一
关闭也不会导致删除,因为其是随内核持续的
即使没有哪个进程打开一个信号量,这个对象里的值也会呆在内核中
成功为0,出错为-1
int sem_unlink(const char * name);
将对象从内核中删除(引用计数为0的时候)
成功为0,出错为-1
int sem_wait(sem_t * sem);
int sem_trywait(sem_t * sem);
如果信号量的值大于0,那么将他减一然后返回,否则等待
后者是非阻塞版本,如果不能减一返回,那么就errno(EAGAIN)
成功为0,出错为-1
int sem_post(sem_t * sem);
原话:《将信号量值加一,然后唤醒正在等待该信号量值正在等待该信号量值变正数的任意进程》
这里的后半句有些迷惑,首先书中展示了一种情况:
不同的系统上,有的系统系统信号量值可以变为负数,有的系统信号量值最低只能为0
显然,可以表示负数的系统说明了当前正在有多少个进程等待信号量
判断系统是否可以表示负数可以使用随后的sem_getvalue()函数
书中还展示了另一种情况:
创建一个信号量,初值为0,再调用两次sem_wait()
信号量值变为-2,接着又调用一次sem_post(),信号量值变为-1的时候
第一个调用sem_wait()的程序继续运行了
(结)当有若干个进程在sem_wait()等待:
信号量值可以为负数的系统:
信号量值为负数:信号量值加一,一个正在等待的进程被唤醒,但信号量值不会再减一了
信号量值不可以为负数的系统:
信号量值为0: 信号量值加一,一个正在等待的进程被唤醒,信号量值被随后的sem_wait()调用减一
成功为0,出错为-1
int sem_getvalue(sem_t * sem, int * valp);
返回某个信号量的当前值
----共享内存信号量的创建
关于共享内存信号量的一些说明:
对于共享内存信号量,应保证且只有一次的sem_init()调用,多次调用的行为是未定义的。
除了创建和销毁外,使用方法和有名信号量一样,都是调用sem_wait()、sem_post()等
其持续性前面提到至少跟随进程,但实际上,只有含有这个信号量的共享内存区保持有效,信号量就有效
关于共享内存的信号量还有一个迷惑,这同样是system V信号量存在的问题,但后者有解决方法:
一个信号量首先被放到了共享内存中,在这个信号量在被sem_init()前,
因为不同进程间的竞争,其他进程使用了这个信号量,怎么办?
也许看到共享内存那章的时候会有解决方法吧。或者说,应该先调用sem_init(),再放到共享内存?
出错返回-1,没有说明成功为0
int sem_init(sem_t * sem, inst shared, unsigned int value);
sem: 参数应该是一个由使用者以及分配好的空间,并传递到该参数
shared:
为0: 该信号量由同一进程的线程共享
非0(一般为1): 由不同进程间共享,但是这个信号量必须再共享内存中,
要使用该信号量的进程也需要有访问该共享内存的权限
因为本书中的共享内存在随后章节中,所以书中的例子都是基于线程的,也就是shared为0
value: 为信号量的初值
成功为0,出错返回-1
int sem_destroy(sem_t * sem);
没什么好说的,对应创建,对应销毁
2021年11月8日11:26:32 10章
十一 system V信号量
posix信号量提供的接口是对于单个信号量的操作,但在system V上状况不一样了。
system V是随内核持续的。
system V提供的接口是关于一个信号量集合的,一个信号量集合中有若干信号量,一个集合对应于一个semid_ds结构(见下)。
几个贯穿本章关于system V信号量信号量的结构:
struct semid_ds // 信号量集合对应的结构
{
struct ipc_perm sem_perm; // 该结构在第三章
struct sem * sem_base; // 这是一个数组,每个元素对应一个具体的信号量,该结构见下。
ushort sen_nsems; // 数组的长度
time_t sem_otime; // 最后一次对该集合调用semop()函数的时间
time_t sem_ctime; // 创建的时间,或者最后一次使用IPC_SET标志调用semctl()函数的时间
};
struct sem // 单个信号量对应的结构
{
ushort_t semval; // 信号量的值
short sempid; // 最后一次成功调用semop()函数的进程id
ushort_t semncnt; // 等待semval变成 大于某个值的线程数,这里的某个值存储在哪里,详见semop
ushort_t semzcnt; // 等待semval变成 0的线程数
----
对于每个信号量对象,还有一个隐含的成员,它是semadj,这个成员不在这个结构中
它由内核维护,并且这个成员只有在 调用semop()并且指定了SEM_UNDO标志 时才会修改
关于SEM_UNDO和semadj的作用,在semop()中说明
----
}
system V虽然只提供了三个函数,但三个函数本身所携带的信息量却很大,这里分开写:
semget函数:
成功返回非负标识符,出错返回-1
int semget(key_t key, int nsems, int oflag);
返回值: 返回的标识符传递给semop和semctl代表了哪个信号量集合,在系统范围内是唯一的。
key: 可通过第六章中的ftok函数得到。
nsems: 该集合中信号量的个数,semid_ds.sen_nsems成员
oflag: (读)SEM_R、(修改写)SEM_A、IPC_CREAT、IPC_EXCL或是第三章的rwx权限
semget函数将对应的semid_ds结构的<大部分>成员初始化,sem_base数组中每个具体的元素却不会初始化
这需要调用semctl函数来对每个成员初始化,也就是说一个信号集的初始化,需要分成两步
第一步是semget创建,第二步是semctl初始化每个信号元素,由此便产生了一个竞争:
进程A创建了一个信号集S,调用semget之后,调用semctl之前,进程B打开信号集S,然后对其调用semop。
解决方法:
semget创建一个信号集时,保证semid_ds.sem_otime被初始化为0,而该成员是最后一次成功调用semop的时间。
进程A中:semget->semctl->semop,最后这一次semop可以不做什么事情
进程B中:semget->持续测试semid_ds.sem_otime变为非0,变为非0后,那么A对semctl必然完成了
关于如何取得semid_ds.sem_otime的值,在semctl函数中说明
semop函数:
成功为0,出错返回-1
int semop(int semid, struct sembuf * opsptr, size_t npos);
semid: 由semget返回的那个标识符,指明是哪个信号集
opsptr: 一个sembuf的数组,可以同时对多个元素同时操作
npos: opsptr数组的长度
因为opsptr是一个数组,指定了npos个操作。
如果这个npos个操作有任意一个不能完成,那么opsptr数组所指明的npos个操作都不会执行。
所以一种使用方式或许是每次调用时指定npos为1,完成一个操作,这样就不会影响到其他。
---------------------------------------------------------------------------
struct sembuf
{
short sem_num; // 用作semid_ds.sem_base的索引,指明是哪个信号量元素
short sem_op; // 做什么操作,详见下
short sem_flag; // 0、IPC_NOWAIT、SEM_UNDO,一个是非阻塞标志,至于SEM_UNDO标志,在本段随后说明
---
关于sembuf结构,保证有这三个成员,但是还可能有其他成员,且内存分布也可能不一样
所以必须用sembuf.sem_num = 999;这样的方式来为成员赋值。
---
};
semop的使用几乎全部聚集在了sembuf成员的值的说明。
sembuf.sem_op 大于0: 《释放资源》,把 sembuf.sem_op的值 加到 semval上。
就像:semid_ds.sem_base[sembuf.sem_num].semval += sembuf.sem_op;
sembuf.sem_op 等于0: 希望等待semval变为0。
如果已经是0立即返回。
如果不是semzcnt+=1,阻塞等待,阻塞返回的时候semzcnt-=1。
sembuf.sem_op 小于0: 《申请资源》,希望等待semval变为大于等于sembuf.sem_op的值。
如果已经大于等于sembuf.sem_op的绝对值,那么从semval中减去sembuf.sem_op的绝对值。
如果小于sembuf.sem_op的绝对值,semncnt+=1,那么阻塞,返回时semncnt-=1,同时也会从semval中减去sembuf.sem_op的绝对值。
IPC_NOWAIT:非阻塞标志
当没有指定这个标志,又获取不到资源,那么阻塞,还有可能被打断,导致errno==EINTR
当指定了这个标志,获取不到资源将导致errno==EAGAIN。
SEM_UNDO: 在本章开头的struct sem结构中提到了semadj的存在。
这个标志在sembuf.sem_op非0的情况下使用。
semadj的存在依附于信号集,但其实例对象却对应每个进程都有一份。
每当有一个进程打开一个信号集,那么内核里就会有一个对应某个信号semid_ds.sem_base[i]的semadj出现。
每当semop操作且指定了SEM_UNDO时,不论sembuf.sem_op是大于0还是小于0,都会导致sembuf.sem_op的值加到对应semadj。
如:
semadj初始为0
第一次semop导致semval+=5,semadj+=5,此时semadj为5
第二次semop导致semval-=3,semadj-=3,此时semadj为2
......
第n此导致semadj为n
最后程序结时,不论其是exit、异常终止还是其他,都会导致semval-=n
如果semadj大于0,那么semval减去了一个整数,《相当于申请资源》
如果semadj小于0,那么semval减去了一个负数,《相当于释放资源》
semval变回了程序开始时候的值。
小结: 设想一个生命周期很短的进程,他简单调用几次semop然后结束。
在这个进程中调用了semop同时指定了SEM_UNDO申请资源,随后程序结束,
系统通过semadj的值帮我们归还了资源,而不用我们给semop传递一个大于0的sembuf.sem_op手动归还
SEM_UNDO标志作用就是帮我们免去了在semop之后归还资源或者收回资源的过程
semctl函数:
成功返回非负值,0表示成功,出错返回-1
int semctl(int semid, int semnum, int cmd, union semun arg);
返回值: 根据cmd参数的不同而不同
semid: 由semget返回的那个标识符,指明是哪个信号集
semnum: 用作semid_ds.sem_base的索引,指明是哪个信号量元素
cmd: 一组固定的常量值。详见下
arg: 一个联合体,根据cmd值不同,使用其不同的成员
union semun
{
int val; cmd值为SETVAL时使用
struct semid_ds *buf; cmd值为IPC_SET、IPC_STAT时使用
ushort *array; cmd值为GETALL、SETALL时使用
----该联合体需要用户自己声明,且传递时应该:semctl(semid, 0, XXX, args);
}args;
cmd的常量值:
GETVAL: semval作为返回值返回
SETVAL: 把semval值设为arg.val,如果操作成功,还会把所有进程中存在的那一份semadj设为0
GETNCNT: semncnt作为返回值返回
GETZCNT: semnznt作为返回值返回
GETPID: sempid作为返回值返回
GETALL: 返回值本身为0,通过arg.array指针返回信号集内所有信号量的semval值,arg.array指向的数组由用户分配且足够长
SETALL: 设置所有信号的semval值,通过arg.array指定
IPC_SET: 通过arg.buf,设置semid_ds.sem_perm.uid、semid_ds.sem_perm.gid、semid_ds.sem_perm.mode。sem_ctime也会变为当前的时间。
IPC_STAT: 通过arg.buf,返回信号量集的semid_ds结构,可以通过此取得semid_ds.sem_otime。
IPC_RMID: 将semid指定的信号量从系统中删除。
2021年11月9日17:54:00 11章
十二 共享内存之文件映射
fork的父子进程间有独立的地址空间,但是mmap的映射关系在fork后会被继承。
成功返回被映射区的起始地址,出错为MAP_FAILED
void * mmap(void * addr, size_t len, int prot, int flags, int fd, off_t offset);
addr: 指定一个地址,希望使被映射的对象的起始地址在这个addr上,但不保证
len: 在程序中,从返回值开始的起始地址,这块空间的长度,同样文件也最多映射这么多字节过来
prot: 这块区域(映射区域)的读写权限。
PROT_READ、PROT_WRITE、PROT_EXEC、PROT_NONE,分别是读写执行和不可访问。
flag: MAP_SHARED 变动是共享的,修改也会修改文件,但何时写回去是一个问题,间msync函数。
MAP_PRIVATE 变动是私有的,修改只会内存中的副本,不会同步到文件
MAP_FIXED 准确的解释addr参数,看不懂是什么意思,因为可移植性,这个不应该被指定
MAP_SHARED和MAP_PRIVATE必须指定其中其中之一,
fd: 应该是一个被打开的文件描述符,但其不应该是一个终端或者套接字,此类对象必须通过write或者read。
另外,当mmap成功打开后,这个fd被关闭也不会影响,也许mmap本身也是一次引用计数的打开。
offset: 从fd关联的描述符开始处,偏移多少个字节映射到内存中。
成功为0,出错返回-1
int munmap(void * addr, size_t len);
addr: mmap返回的那个地址
len: 映射区地址长度
从地址空间中删除一个映射关系
成功为0,出错返回-1
int msync(void * addr, size_t len, int flags);
将addr处开始,len长度个字节写到文件。
addr: 可以是mmap返回的那个值,也可以是其的一个子集,那么就只写回一部分
len:
flags: MS_ASYNC 异步写,使用该值调用函数,如果写操作已经被挂起到内核,立即返回。
MS_SYNC 同步写,使用该值调用函数,将阻塞到写完成
MS_INVALIDATE 使高速缓存的数据失效,或者说,所以与文件 不一致的内存中的副本 都会失效,然后再同步过来。
MS_ASYNC和MS_SYNC必须指定其中之一,
本章中还展示了一些,比如说在被映射的fd开始处,write一个结构体,然后mmap返回addr,就可以将addr转为该类型的结构,直接操作其成员。还有使用lseek偏移文件指针,然后在这个位置写入一个空字节,文件就扩张到了指定的长度。
另外还着重说明了内存映射长度和文件本身的长度,有着什么样的交互。
首先要明确,这里有三个长度。
①文件本身的长度。
②页面文件相关的长度。
③使用mmap时内存映射空间的长度。
当① >= ③时,假设文件本身5000字节,而页面大小4096字节,那么5000字节文件需要两个页面(0-8191共8192个字节),在程序中0-8191这些个字节都是可以访问的,但是超过5000字节的部分读写行为是无效的,超过8191范围的时候,将导致SIGSEGV,引发段错误,然后程序结束。
当①(5000) < ③(10000)时,文件大小、页面大小和前面一样,同样访问0-8191这个范围不会有错误,访问8191-9999时产生SIGBUS信号,访问9999之后的地址时,产生SIGSEGV信号。
2021年11月10日7:37:40 12章
十三 posix共享内存
posix共享内存与内存映射的异同:
内存映射:通过open打开文件,返回一个描述符,再由mmap映射到地址空间。
posix共享内存:通过shm_open创建或打开一个路径,同样返回一个描述符,再由mmap把这个描述符映射到地址空间。
虽然open和shm_open返回的都是描述符,但是在后续的ftruncate函数里是不一样的,前者的描述符被视为普通文件,后者的描述符被视为一个共享内存对象。
成功返回非负描述符,出错返回-1
int shm_open(const char * name, int oflag, mode_t mode);
返回值:一个文件描述符,随后被用于mmap
name: 一个打开或创建的路径名
oflag: 打开还是创建,见第二章
mode: 文件的rwx权限,见第二章
成功返回0,出错返回-1
int shm_unlink(const char * name);
将路径名对应对象删除,同样是引用计数为0时删除
成功返回0,出错返回-1
int ftruncate(int fd, off_t length);
fd: 描述符
若是一个普通文件(open打开的):
文件长度比length大,多余的部分被丢弃。
文件长度比length小,文件是否扩展是未定义的。
一个可移植的扩展文件的方法:lseek到length-1处,写入一个空字节。
若是一个共享内存对象(shm_open打开的):把该对象的大小设置成length字节
length:文件扩展到多长
成功返回0,出错返回-1
int fstat(int fd, struct stat * buf);
fd: 一个文件描述符,这里主要说shm_open返回的那个描述符
buf: 当fd是由shm_open返回的时候,只有四个成员的值有效:st_mode、st_uid、st_gid、st_size
这里主要就是通过取得st_size来确定共享内存的长度。
这里同样存在一个竞争,创建一个共享内存对象后,他的默认长度是0,若两个进程一个创建一个打开,创建之后却没有及时设定长度,那么打开的那个进程,就只能通过fstat来判断长度变为非0值。
是先ftruncate还是先mmap?
书中的例子,这两种情况都有,也没有表现出什么问题。
十四 system V共享内存
poisx 与 system V共享内存异同:
posix:shm_open创建或打开,mmap映射到地址空间,共享内存区大小可随时调整。
system V:shmget创建或打开,shmat映射到地址空间,不过在system V中,这个映射的过程被称为附接。共享内存区大小只能在创建时设置。
每个共享内存对象都会关联的一个对象
struct shmid_ds
{
struct ipc_perm shm_perm; // 第三章,每个system V IPC都有的成员
size_t shm_segsz; // 共享内存区的长度
pid_t shm_lpid; // 最后一个操作共享内存的进程
pid_t shm_cpid; // 创建者进程
shmatt_t shm_nattch; // 当前被映射(附接)的数量
shmat_t shm_cnattch; // in-core # attached 看不懂
time_t shm_atime; // 最后一次附接的时间
time_t shm_dtime; // 最后一次分离的时间,也许是解除附接?
time_t shm_ctime; // 最后一次改变当前结构体的时间,shmid_ds
};
成功返回共享内存对象,出错返回-1
int shmget(key_t key, size_t size, int oflag);
返回值: 同system V消息队列和信号量,是系统范围内唯一的。
key: 可以是ftok或者IPC_PRIVATE,见system V消息队列那章
oflag: 创建或者打开以及rwx权限,第三章。
成功返回映射区起始地址,出错返回-1
void * shmat(int shimd, const void * shmaddr, int flag);
shmid: shmget的返回值
shmaddr:
空指针:由系统选择地址返回
非空指针:在这个指定的地址上建立映射关系
flag: SHM_RDONLY 只读访问
SHM_RND 导致shmaddr参数指定的非空地址向下对齐到一个SHMLBA的常值
成功1为0,失败返回-1
int shmdt(const void * shmaddr);
shmaddr: shmat返回的那个映射地址
该函数用作解除映射
成功1为0,失败返回-1
int shmctl(int shmid, int cmd, struct shmid_ds * buff);
shmid: shmget的返回值,指明哪个共享内存对象
cmd: IPC_RMID 解除映射,并从系统中删除该共享内存
IPC_SET 通过buff参数设置shmid_ds.shm_perm.uid、shmid_ds.shm_perm.gif、shmid_ds.shm_perm.mode
IPC_STAT 通过buff返回该共享内存的shmid_ds结构
buff: 缓冲区
2021年11月10日18:02:36 13、14章
十五 单主机的远程过程调用
门,提供了一台主机上,一个进程调用另一个进程的接口。
调用者称为客户,被调用者称为服务器。
单机的远程过程调用主要由两个作用:
①传递一些数据,给服务器处理,然后返回
②在服务器和客户间互相传递已打开描述符
以下是简单的流程,详见后。
服务器:
①door_create创建一个门,同时关联通过该门会执行哪个过程函数
②fattach将该门和一个路径名附接(关联)
③fdetach取消附接
④door_revoke撤销门,或者说删除门
客户:
①使用open打开fattach关联的那个路径
②door_call门和需要的参数,发生远程过程调用,等待服务器dorr_return返回,door_return的调用应该放在过程函数中。
③关闭文件。
门过程调用中客户或者服务器之一终止:
若是服务器先于客户终止,客户的door_call将导致EINTR错误。
door_call也可能会被信号打断,从而发生EINTR错误。
如果在door_call阻塞时客户被终止,会向服务器发送取消那个被线程池执行Door_server_porc的线程信号,如果这个这个线程是可以被取消的,那就取消它,然后调用对应的线程的清理函数。但这些线程默认是不可取消的,他们会执行完毕,door_return的结果被丢弃。
当door_call被打断,那么是否应该重新调用,对于客户来说,他要一个结果返回,对于服务器来说,每当有一个door_call调用,服务器就会有一个线程被启动执行,重新调用的行为是否安全,应该取决于用户自定义的那个过程函数多次执行是否安全。
该结构在传递描述符使用
struct door_desc_t
{
door_attr_t d_attributes; // DOOR_DESCRI[TOR 说明联合体传递表示的描述符
// DOOR_RELEAS 在描述符被传递后自动关闭这些被传递的描述符,否则就需要用户自己处理哪些描述符了
union // 是个联合体,但只有一个成员,可能是为了以后的可扩展性?
{
struct
{
int d_descriptor; // 要传递的那个描述符
door_id_t d_id; // 书中只说这个成员是《唯一id》,例子也没有填写这个成员,也许是door_info_t.di_uniquifier?
}d_desc;
}d_data;
};
成功为0,出错返回-1
int door_call(int fd, door_arg_t * argp);
因为有了door_arg_t这个结构才方便说明door_create
fd: 使用open打开一个路径名时返回的描述符。
argp: struct door_arg_t
{
char *data_ptr; // 传递数据给服务器过程调用时的数据区,可以在此基础上定义一个结构体来解释
size_t data_size; // data_ptr的长度
door_desc_t *desc_ptr; // 传递描述符时使用的结构,是一个数据,因为可以同时传递多个
size_t desc_num; // 传递描述符的数量
char *rbuf; // 服务器的返回值的缓冲区
size_t rsize; // 返回值缓冲区的长度
};
传递数据过程调用时,desc_ptr和desc_num一般为null和0。
同样,传递描述符时,data_ptr和data_size一般为null和0。
当然,同时使用也不是不行。
door_call的调用是同步的阻塞的,他会等待到服务器返回。
给data_ptr、desc_ptr、rbuf指定同一个地址也是可以的,door_call调用时这块地址存参数,返回后这块地址存结果。
door_call在返回的时候,不论原值是什么,其data_ptr和desc_ptr都会被指向door_call调用时rbuf的地址,没有为什么。
如果没有数据结果或者描述符传递结果,那么data_size或desc_num为0。
如果rbuf的长度容纳不了服务器的结果,那么会导致门调用mmap来分配一个新的缓冲区
而这个缓冲区需要用户来调用munmap来归还给系统
那么监测rbuf成员是否被改变,也成了用户的责任
成功返回非负描述符(一个门),出错返回-1
int door_create(Door_server_proc * proc, void * cookie, u_int attr);
proc: 一个函数指针,当客户调用door_call,该函数会被调用,用户应该自定义该函数,其原型为:
typedef void Door_server_porc( void * cookie,
char * dataptr, size_t datasize,
door_desc_t * descptr, size_t ndesc);
其中cookie参数是door_create的第二参数,被调用时自动传递
剩下的四个参数依次是door_arg_t结构的前四个成员,door_call传递的数据在这里取得,然后在这个函数进行处理
他的返回值是void,不是不应该返回,而是我们应该在这个函数内部手动调用door_call返回。
cookie: 并没有看明白意义,书中例子也一直为0.
attr: 0或者以下两个常值按位或
DOOR_PRIVATE 每当有一个door_call请求,服务器启动一个线程来调用Door_server_porc
当不指定该值,将从进程的线程中取,否则要求门需要有一个自己的线程池,并且在其中调用
DOOR_UNREF 当指代该门的描述符从2降为1,Door_server_porc的dataptr将被指定为DOOR_UNREF_DATA
然后进行依次调用,就像Door_server_porc(0, DOOR_UNREF_DATA, 0, 0, 0);
door_create返回的描述符算作对门的一次引用。
fattach的附接也算做一次对门的引用。
客户open也算一次对门的引用。
不论这些引用的先后失效如何,只要引用计数从2降为1,就会发生一次调用
DOOR_LOCAL 该过程只用于本进程
DOOR_REVOKE 已被调用door_revoke
成功为0,出错返回-1
int door_revoke(int d);
d: door_create的返回值
撤销门,或者说删除门,就像关闭一个描述符。
成功不返回到调用者,返回到door_call处,出错返回-1
int door_return(char * dataptr, size_t datasize, door_desc_t * descptr, size_t * ndesc);
返回数据结果使用dataptr和dattasize,传递描述符时使用descptr和ndesc。
同样,使用一组时另外一组为null和0,同时使用也没什么问题。
成功返回0,出错返回-1
int fattach(int fildes, const char *path);
fildes: door_create的返回值
path: 将该路径名和door_create创建的门关联,如此客户便可通过open该路径名得到描述符,然后对齐door_call调用
成功返回0,出错返回-1
int fdetach(const char *path);
解除一个路径上和门的关联
成功返回0,出错返回-1
int door_cred(door_cred_t * cred);
cred: 服务取得客户的信息,通过该参数返回
这个函数应该在Door_server_porc中调用,就是那个我们自定义的《服务器过程》
struct door_cred_t
{
uid_t dc_euid; // 有效客户id
gid_t dc_egid; // 有效客户组id
uid_t dc_ruid; // 实际客户id
gid_t dc_rgid; // 实际客户组id
pid_t dc_pid; // 客户进程id
};
成功返回0,出错返回-1
int door_info(int fd, door_info_t * info);
fd: 客户open打开的那个描述符
info: 通过该参数返回服务器的相关信息
struct door_info_t
{
pid_t di_target; // 服务端进程id
door_ptr_t di_proc; // 在服务进程中Door_server_porc函数的地址,但是不同的地址空间,也没什么意义
door_ptr_t di_data; // 门创建时cookie参数
door_attr_t di_attributes; // 门相关的属性 DOOR_PRIVATE、DOOR_UNREF、DOOR_LOCAL、DOOR_REVOKE
door_id_t di_uniquifier; // 每个门创建时被赋予的 系统范围内的唯一值
};
door_server_create;
door_bind;
door_unbind;
这三个函数用户自己管理门服务器的线程池,略略略。
2021年11月11日20:44:21 15章
十六 主机间的远程过程调用
自己写了一个C++版的,win和linux都可用,见https://blog.csdn.net/qq_43082206/article/details/121354643