Linux进程间通信源码分析
概览
这篇文章从内核、glibc库源码的角度整理一下Linux的进程间通信机制。
众所周知,Linux操作系统的通信机制有以下几种:
- 信号
- 管道(分为匿名管道和有名管道)
- 信号量
- 共享内存
- 消息队列
- Socket
本文主要内容包括其中前五个。
其中信号量、共享内存、消息队列在Linux中有两套API,实现方式大不相同:
- System V Api, 主要由内核自行实现,移植性不高。
- Posix Api,由外部库函数实现,移植性较高。
System V进程间通信接口:
Posix进程间通信接口:
-
消息队列 mq_open(3)
-
共享内存 shm_open(3)
-
信号量分为有名和匿名信号量sem_open(3) sem_init(3)
本文的不足:本人尚未积累太多开发经验,本文章的整理内容主要偏向于源码实现,大多数内容参考自书籍,几乎没能从应用层实践的角度给出自己的见解。希望我工作一段时间后,再来做补充。
信号
本章内容主要参考linux内核2.6版本,与2.4版本相比有一些变化,变得更清晰了。
本章先介绍以下内核中有关信号的数据结构,当然仅仅是挑选了几个有代表性(这里参考了《深入Linux内核架构》一书);
然后结合源码(Linux 2.6和2.4)看看内核是怎样发送信号、捕获信号、处理信号的;
最后信号处理函数的执行是在用户态的,这其中涉及到了用户与内核态的反复切换,比较有意思。
内核相关数据结构
task_struct结构中与信号有关的属性如下:
struct task_struct{
struct sighand_struct *sighand;
sigset_t blocked;
struct sigpending pending;
unsigned long sas_ss_sp;
size_t sas_ss_size;
}
另外thread_info结构中有一个TIF_SIGPENDING标识位,如果该标志位为1,则标识进程有未决信号。
这是与linux2.4版本有着较大的区别,因为该标识为在2.4内核存放与task_struct结构中。而在2.4版本,task_struct结构存放于内核栈的最后,但由于task_struct结构越来越大,内核开发者们在之后的版本中使用thread_info代替了task_struct存放与内核栈最后。thread_info结构有一个指向task_stuct结构的指针,而task_struct结构本身则由slab分配器管理。
可能是为了效率的考量,开发者将一些标志位直接放在了thread_info中,其中就包括TIF_SIGPENDING标志位。
struct thread_info {
// ...
struct task_struct *task; /* main task structure */
unsigned int flags; // 标识位
// ..
}
值得一提的是,2.6内核中使用32位无符号整数标识32个标识位,TIF_SIGPENDING标识只占用其中一位。其他的比较重要的标识位有TIF_NEED_RESCHED,标识进程是否应该被抢占,每次从内核返回用户空间时,都会检查这一标识,如果标识位置1,则转而调用schedule进行调度,这就表明本进程被其他进程抢占了。
再回到信号处理的相关数据结构中
-
sighand_struct结构包含一个数组,这个数组有64个元素,每个元素都是一个k_sigaction结构。而k_sigaction仅仅是sigaction的一层包装,sigaction就是用户设置相关信号处理函数时用到的数据结构。所以,64个sigactin中的每个都是对某种信号的反应,用户可以使用系统调用sigaction, 将sigaction.sa_handler设置成自定义函数,这样当进程收到信号时,会执行这个函数。当sigaction.sa_handler位SIG_DFL时,内核将执行信号对应的默认反应
struct sigaction { __sighandler_t sa_handler; unsigned long sa_flags; sigset_t sa_mask; /* mask last for extensibility */ };
-
blocked成员,是一个long型变量,64位。相当于一个bitmap,如果其中某一位为1,则表示进程屏蔽了该信号,不会响应。
-
sigpending成员管理本进程所有还没有被处理的信号。
struct sigpending { struct list_head list; sigset_t signal; };
其中的sigpending.signal也相当于一个64位的bitmap,如果某位置1,表示该信号还没有被处理。
每个未处理信号使用sigqueue结构表示:
struct sigqueue { struct list_head list; siginfo_t info; };
它们通过list_head链入sigpending中。
-
在执行信号处理函数时,进程是运行在用户态的,需要额外的一个栈。如果用户额外地准备了一个运行栈,则sas_ss_sp指向这个栈,sas_ss_size则表示了这个栈的大小。但一般情况下,sas_ss_size = 0, 表示信号处理运行时栈与原始的用户栈共用。
相关数据结构的关系如下图所示:
以上是内核中的数据结构,现在再来看一下操作系统提供给用户的接口。
传统的Linux信号量有32个,之后由加入了32个实时信号,现在一共有64个信号,可以使用kill -l 命令查看:
kill -l
1) SIGHUP 2) SIGINT 3) SIGQUIT 4) SIGILL 5) SIGTRAP
6) SIGABRT 7) SIGBUS 8) SIGFPE 9) SIGKILL 10) SIGUSR1
11) SIGSEGV 12) SIGUSR2 13) SIGPIPE 14) SIGALRM 15) SIGTERM
16) SIGSTKFLT 17) SIGCHLD 18) SIGCONT 19) SIGSTOP 20) SIGTSTP
21) SIGTTIN 22) SIGTTOU 23) SIGURG 24) SIGXCPU 25) SIGXFSZ
26) SIGVTALRM 27) SIGPROF 28) SIGWINCH 29) SIGIO 30) SIGPWR
31) SIGSYS 34) SIGRTMIN 35) SIGRTMIN+1 36) SIGRTMIN+2 37) SIGRTMIN+3
38) SIGRTMIN+4 39) SIGRTMIN+5 40) SIGRTMIN+6 41) SIGRTMIN+7 42) SIGRTMIN+8
43) SIGRTMIN+9 44) SIGRTMIN+10 45) SIGRTMIN+11 46) SIGRTMIN+12 47) SIGRTMIN+13
48) SIGRTMIN+14 49) SIGRTMIN+15 50) SIGRTMAX-14 51) SIGRTMAX-13 52) SIGRTMAX-12
53) SIGRTMAX-11 54) SIGRTMAX-10 55) SIGRTMAX-9 56) SIGRTMAX-8 57) SIGRTMAX-7
58) SIGRTMAX-6 59) SIGRTMAX-5 60) SIGRTMAX-4 61) SIGRTMAX-3 62) SIGRTMAX-2
63) SIGRTMAX-1 64) SIGRTMAX
如果没有使用sigaction系统调用改变进程对信号的反应方式,进程则执行规定的默认动作。可以使用 man 7 signal查看,下面是POSIX.1-1990 标准:
Signal Value Action Comment
──────────────────────────────────────────────────────────────────────
SIGHUP 1 Term Hangup detected on controlling terminal
or death of controlling process
SIGINT 2 Term Interrupt from keyboard
SIGQUIT 3 Core Quit from keyboard
SIGILL 4 Core Illegal Instruction
SIGABRT 6 Core Abort signal from abort(3)
SIGFPE 8 Core Floating-point exception
SIGKILL 9 Term Kill signal
SIGSEGV 11 Core Invalid memory reference
SIGPIPE 13 Term Broken pipe: write to pipe with no
readers; see pipe(7)
SIGALRM 14 Term Timer signal from alarm(2)
SIGTERM 15 Term Termination signal
SIGUSR1 30,10,16 Term User-defined signal 1
SIGUSR2 31,12,17 Term User-defined signal 2
SIGCHLD 20,17,18 Ign Child stopped or terminated
SIGCONT 19,18,25 Cont Continue if stopped
SIGSTOP 17,19,23 Stop Stop process
SIGTSTP 18,20,24 Stop Stop typed at terminal
SIGTTIN 21,21,26 Stop Terminal input for background process
SIGTTOU 22,22,27 Stop Terminal output for background process
常见的信号有9,即杀死信号. 信号11默认行为是转储然后停止进程运行,该信号应该是C/C++初学者接触最多的信号了吧 :)
其他的信号可以继续查看man手册,这里就不贴了。
信号发送与信号捕获
信号发送
当我们在shell中使用kill命令给一个进程发送9号命令时,一般来说该进程会终止运行。这一节要做的,就是简要地介绍一下,内核是如何向指定进程发送信号的,指定进程又是如何捕获这个信号并做出反应的。
我们先使用 strace 命令跟踪这条shell命令:
$ starce kill -9 14000
execve("/bin/kill", ["kill", "-9", "14000"], 0x7ffffbd0b560 /* 33 vars */) = 0
// ......
kill(14000, SIGKILL)
//......
exit_group(1)
众所周知,shell是一个用户进程,当我们在命令行输入命令时,shell进程进行语法解析,然后fork子进程并在子进程中进行系统调用execve来执行用户想要运行的程序,这里要运行的是/bin/kill程序。而/bin/kill程序则最终调用系统调用kill对pid=14000的进程发送9号信号。
linux2.6.32.10内核对kill系统调用的定义如下:
// linux2.6.32.10/kernel/signal.c
SYSCALL_DEFINE2(kill, pid_t, pid, int, sig)
{
struct siginfo info;
info.si_signo = sig;
info.si_errno = 0;
info.si_code = SI_USER;
info.si_pid = task_tgid_vnr(current);
info.si_uid = current_uid();
return kill_something_info(sig, &info, pid);
}
kill_something_info之后的调用链为:
kill_something_info => kill_pid_info => group_send_sig_info => do_send_sig_info => send_signal => __send_signal
主要来看__send_signal函数:
// t : 给t进程发送信号
// sig : 信号的编号, 如9号为SIGKILL信号
static int __send_signal(int sig, struct siginfo *info, struct task_struct *t,
int group, int from_ancestor_ns)
{
struct sigpending *pending;
struct sigqueue *q;
// ......
// 如果group为0,则pending是task_struct中的pending
pending = group ? &t->signal->shared_pending : &t->pending;
// ......
// 分配一个sigqueue
q = __sigqueue_alloc(t, GFP_ATOMIC | __GFP_NOTRACK_FALSE_POSITIVE,
override_rlimit);
if (q) {
list_add_tail(&q->list, &pending->list); // 加入到task_struct.pending中
// ...
}
complete_signal(sig, t, group); // 这个函数最终会调用signal_wake_up函数,设置TIF_SIGNALPENDING标志位,有可能的话,使得被发送的信号的进程抢占当前进程
return 0;
}
以上代码为这个函数的骨干内容,主要分为两步:
- 分配一个sigqueue结构然后链接入目标进程的task_struct.pending中。
- complete_signal函数处理一些其他的检查,然后调用signal_wake_up。
- signal_wake_up函数将设置目标进程的TIF_SIGPENDING标志位为1,然后调用wake_up_state => try_to_wake_up将目标进程唤醒。
- try_to_wake_up函数,将目标进程的task_struct.state设置为TASK_RUNNING,并且将调用check_preempt_curr函数检查目标进程是否可以抢占当前进程,如果可以,那么在函数返回后,当前进程的TIF_NEED_RESCHED标志位将置为1,当本进程从内核态返回至用户态时,将调用一次schedule函数。于是本进程就被抢占了,但是下一个运行的进程是否就是目标进程呢?个人认为不一定,schedule函数还要自己计算一次优先级,选择优先级最高的进程恢复执行,优先级最高的进程不一定就是信号发送的目标进程。
- signal_wake_up函数将设置目标进程的TIF_SIGPENDING标志位为1,然后调用wake_up_state => try_to_wake_up将目标进程唤醒。
到现在为止,我们的shell进程的子进程/bin/kill已经向目标进程发送了信号,具体的表现为:
- 目标进程的task_struct.pending中多出了一个sigqueue,具体描述了一个信号(信号编号sig、发送信号的进程pid等)。而sighand结构所保存的sigaction数组保存所有的信号处理函数,这里sighand[sig]所指向的函数就是相应的信号处理函数。
- 目标进程的thread_info.flag的TIF_SIGPENDING被置1,表示该进程有未决信号。
/bin/kill进程只负责发送信号,至于信号的捕获与执行,都交给目标进程来做。
信号捕获
进程如何察觉到另一个进程给它发送了信号呢?这依赖于entry.S中相关代码,该文件定义了中断\系统调用的入口代码,以及中断\系统调用返回时需要做的额外工作,这些额外工作,就包括对信号的检查。linix2.6.32.10/arch/x86/kernel/entry_64.S文件上方有这样一段注释:
/* entry.S contains the system-call and fault low-level handling routines.
* NOTE: This code handles signal-recognition, which happens every time after an interrupt and after each system call.
* 渣翻: 这段代码会处理信号识别,这发生再每一次中断和系统调用之后。
*/
当一个进程从内核态返回用户态时,entry.S的相关代码会调用do_notify_resume函数,该函数检查TIF_SIGPENDING标识未是否置为1,如果是那就调用do_signal(该函数在下一节详细分析)处理该信号:
void
do_notify_resume(struct pt_regs *regs, void *unused, __u32 thread_info_flags)
{
// ...
if (thread_info_flags & _TIF_SIGPENDING) // 检查_TIF_SIGPENDING标识是否为1,若为1则调用do_signal
do_signal(regs);
// ...
}
可能linux2.4版本更容易看出内核在返回用户态时对SIGPENDING进行了检查,具体代码也位于entry.S文件的ret_with_reschedule处:
ret_with_reschedule:
cmpl $0,need_resched(%ebx) #检查 need_resched标志,如果为1则调用schedule
jne reschedule
cmpl $0,sigpending(%ebx) # 检查 sigpending标志,如果为1则先处理信号
jne signal_return
signal_return:
sti # we can get here from an interrupt handler
testl $(VM_MASK),EFLAGS(%esp)
movl %esp,%eax
jne v86_signal_return
xorl %edx,%edx
call SYMBOL_NAME(do_signal) # 也是调用do_signal 函数!
jmp restore_all
好了说了这么多,我只想说明无论是linux2.6或者时2.4或者更高的内核版本,进程对信号的捕获都发生在内核返回至用户态的那一刻,它会检测SIGPENDING标志位是否为1,如果为1,表示进程有未决信号,那么此时就要去处理函数,处理完了再返回至用户态。
可以看到,从信号递送成功,再到信号被捕获,其中的时间间隔是不确定的。这个时间间隔主要取决于进程的优先级,如果该进程的优先级高,那么它被schedule函数选中的概率就大,那么该进程就会更快地从内核返回至用户态,也能更快地捕获信号。
但是以上结论是在进程A对进程B发送信号的场景下。如果内核在一次中断/系统调用中对当前进程发送信号,那么由于本进程本来就处于内核态,可以认为此时的这个信号立即被处理。
举个例子,进程C使用了还没有向操作系统分配的内存,会发生pagefault进入内核态处理异常,内核发现该地址还没分配给用户,于是内核向进程C发送SIGV信号,然后返回用户态前进程C立刻捕获这个SIGV信号,该信号的处理方式默认为终止进程运行、转储核心文件。然后进程C就结束了运行,用户“高高兴兴”地看见他熟悉的Segmentaion Fault错误提示。
信号处理函数的执行
从前面一节知道了进程何时捕获信号,这一节讲讲进程如何处理信号并执行信号处理函数。
接着上一节,进程捕获信号后调用do_signal函数对信号进程处理:
static void do_signal(struct pt_regs *regs)
{
struct k_sigaction ka;
siginfo_t info;
int signr;
sigset_t *oldset;
// ...
signr = get_signal_to_deliver(&info, &ka, regs, NULL);
if (signr > 0) {
// ....
//
if (handle_signal(signr, &info, &ka, oldset, regs) == 0) {
current_thread_info()->status &= ~TS_RESTORE_SIGMASK;
}
return;
}
// 一些其他检查...
}
do_siganl函数大致上分为两步:
- 调用get_signal_to_deliver函数取得要处理的信号。这个函数会在一个循环中预先对信号的默认行为进行处理:
- 若信号的默认行为是忽略,则继续找下一个
- 若信号的默认行为是停止,则先看看它是否需要转储核心文件,然后直接调用do_group_exit使进程终止运行(比如,上一节开头的那个例子,我们再shell命令行中使用kill命令发送9号信号给目标进程,那么目标进程检测到这个信号后将立刻退出,不再会有下面的步骤了)
- 若信号有一个用户指定的动作,则跳出循环,将这个信号返回给上层do_signal函数
- 到这一步骤表明得到了一个非默认行为的信号,这个信号的处理函数被用户另外指定,于是再调用handle_signal函数去真正地处理它。
在分析handle_signal函数前,首先明确,用户规定的信号处理函数是在用户态运行的,这需要一个额外的用户栈。内核要做的就是将trapframe(ptregs)中的eip修改为信号处理函数的地址,然后分配一个额外的用户栈来执行信号处理函数,将trapframe中的esp指向这个额外的用户栈。而且,当用户处理函数执行完毕,应该使其再返回至内核,由内核恢复用户进程在信号处理前的运行上下文。
因此handle_signal的主要任务有:
- 建立额外运行时栈,并修改运行时栈使其在执行完信号处理函数后再次系统调用至内核
- 修改trapframe(ptregs)相关参数,使得进程从内核返回值处理函数开始执行
下面来看handle_signal实现:
static int
handle_signal(unsigned long sig, siginfo_t *info, struct k_sigaction *ka,
sigset_t *oldset, struct pt_regs *regs)
{
int ret;
// ...
// 建立信号处理函数运行栈
ret = setup_rt_frame(sig, ka, info, oldset, regs);
// ...
return 0;
}
可以看到,handle_signal函数的骨干只是调用了setup_rt_frame函数,看名字就可以猜想它的功能是建立信号处理函数的运行栈,该函数会调用__setup_rt_frame函数,主要工作就在这个函数完成,分段来看这个函数:
static int __setup_rt_frame(int sig, struct k_sigaction *ka, siginfo_t *info,
sigset_t *set, struct pt_regs *regs)
{
struct rt_sigframe __user *frame;
void __user *fp = NULL;
int err = 0;
struct task_struct *me = current;
frame = get_sigframe(ka, regs, sizeof(struct rt_sigframe), &fp);
// 一些检查 ......
可以看到__setup_rt_frame首先调用get_sigframe来获取一个rt_sigframe,rt_sigframe是信号处理函数运行时栈的一部分,其中包括返回地址和一些用来恢复用户进程在执行信号处理函数之前的上下文信息。
static inline void __user *
get_sigframe(struct k_sigaction *ka, struct pt_regs *regs, size_t frame_size,
void __user **fpstate)
{
/* Default to using normal stack */
unsigned long sp = regs->sp; //
// 默认使用户原来的运行时栈
int onsigstack = on_sig_stack(sp);
// 架构相关的代码 ......
if (!onsigstack) {
if (ka->sa.sa_flags & SA_ONSTACK) {
// 若用户自己额外提供了一个运行时栈,则使用这个栈
if (current->sas_ss_size)
sp = current->sas_ss_sp + current->sas_ss_size;
}
// 架构相关的代码......
}
// ......
// 如果使用原来的用户栈,相当于只是对栈进行了一下延申。
sp = align_sigframe(sp - frame_size);
// ......
return (void __user *)sp;
}
一般情况下,信号处理的栈与原来的用户栈共用,regs->sp就是用户进入内核时,trapframe中保存的用户栈指针,内核默认使用这个地址减去frame_size的大小作为信号运行时栈的开始:
unsigned long sp = regs->sp;
// ...
sp = align_sigframe(sp - frame_size);
拿到rt_sigframe后,__setup_rt_frame函数对其中的书信赋值,首先复制用户进程进入内核时保存的上下文信息:
// 接上文的__setup_rt_frame
put_user_try {
/* Create the ucontext. */
if (cpu_has_xsave)
put_user_ex(UC_FP_XSTATE, &frame->uc.uc_flags);
else
put_user_ex(0, &frame->uc.uc_flags);
put_user_ex(0, &frame->uc.uc_link);
put_user_ex(me->sas_ss_sp, &frame->uc.uc_stack.ss_sp);
put_user_ex(sas_ss_flags(regs->sp),
&frame->uc.uc_stack.ss_flags);
put_user_ex(me->sas_ss_size, &frame->uc.uc_stack.ss_size);
err |= setup_sigcontext(&frame->uc.uc_mcontext, fp, regs, set->sig[0]);
err |= __copy_to_user(&frame->uc.uc_sigmask, set, sizeof(*set));
然后设置sig_frame中的返回值:
/* x86-64 should always use SA_RESTORER. */
if (ka->sa.sa_flags & SA_RESTORER) {
put_user_ex(ka->sa.sa_restorer, &frame->pretcode);
} // ...
} put_user_catch(err);
可以看到sigframe.pretcode被设置成了ka->sa.sa_restorer,这是个什么?在哪里被赋值?————它在glibc中被赋值,感兴趣的话可以按照下面这个调用路径查看glic2.37的源码:
__sigaction() => libc_sigaction => SET_SA_RESTORER => sa_restorer的定义
该函数是一段汇编代码,我不是很能看懂,但关键信息就是这段汇编代码会执行一次名为rt_sigreturn系统调用,这样就使得信号处理函数执行完后再一次进入内核。
关于pretcode的设置在多说几句。
Linux2.4中,返回函数直接被放置在了用户栈上,因此pretcode直接指向了用户栈的某处地址,可以不必使用ka->sa.sa_restorer这个值。因此《Linux内核源代码情景分析》一文中写道“SA_RESTORER这个标记是过时的,linux man page上已不建议使用”。但如今的man页面又说了该标记“Not intended for application use. ”,表示这个标记不被用户应用使用,而是被glibc库所维护。因此相应的内核代码也倾向于使用ka->sa.sa_restorer来设置返回地址了。
接着再回到__setup_rt_frame函数中,将对用户进入内核时保存的上下文进行修改,其中最重要的两个值是ip和esp。ip指向用户规定的信号处理函数,esp指向用户栈延长的位置:
if (err)
return -EFAULT;
// 信号处理函数所需要的一个形参在这里复制
regs->di = sig;
regs->ax = 0;
// ......
// 修改上下文的ip和sp两个寄存器的值,使得从内核返回值用户处理函数处开始执行。
regs->ip = (unsigned long) ka->sa.sa_handler;
regs->sp = (unsigned long)frame;
// ......
return 0;
}
如此,用户处理函数结束后,会将pretcpde处的地址当作函数返回地址,去执行glibc库定义的restorer函数,在这个函数中进行rt_return系统调用,由内核将用户的原上下文进程恢复:
至于内核怎么恢复的,就不在这里展开细讲了。
匿名管道
从shell的角度看管道
先从shell是怎么处理匿名管道的命令行开始说起,因为这比较直观
我在学习C++时,经常需要查看C++可执行文件的符号表,以确定C++编译器的具体行为,这很简单,只需使用nm命令即可。但是C++对符号名进行了mangle,比如函数声明的函数名为f1(无参数),但是C++编译器将该函数名编码成了 _Z2f1v。为了方便学习,可以使用c++file命令行demangle这些符号名。我们可以直接使用如下shell命令查看C++编译器生成的符号表:
nm cppobj | c++filt
|
这跟竖线就像一个管道,使得nm命令的输出成为c++filt命令的输入,最后c++filt向中断输出最终处理结果。
不妨看一下xv6的shell源码实现,一方面是逻辑结构清晰,另一方面是它确实比较简单。
shell进程将解释上述命令行,在进行一些列词法和语法分析后,得出这个命令是管道命令:
// xv6/sh.c/runcmd()函数片段:
int p[2]; // pipe调用返回两个fd存放在这里
case PIPE:
pcmd = (struct pipecmd*)cmd;
if(pipe(p) < 0) // shell进程调用pipe系统调用,返回后,内存中已经存在了进行通信的缓冲区域
panic("pipe");
if(fork1() == 0){ // 先fork一个进程1, 子进程1进入if代码块。 结合上述场景,这个进程就是nm进程
close(1); // 子进程1关闭标准输出,即fd = 1这个文件描述符不再指向控制台
dup(p[1]); // 子进程1将fd = 1这个文件描述符指向改成 fd = p[1] 这个文件描述符的指向,也即管道的输入端
close(p[0]);
close(p[1]);
runcmd(pcmd->left); // runcmd执行 | 左边的程序,会调用exec不再返回
}
if(fork1() == 0){ // 再fork一个进程2,下面代码块对子进程2做类似处理,但是重定向标准输入的文件描述符。结合上述场景,这个进程为c++filt
close(0);
dup(p[0]);
close(p[0]);
close(p[1]);
runcmd(pcmd->right); // runcmd执行 | 右边的程序,会调用exec不再返回
}
close(p[0]);
close(p[1]);
wait(); // shell进程等待两个子进程执行完毕
wait();
break;
感到神秘的就只有pipe这个系统调用了,但我之后再讲这个玩意,先从shell和它的子进程门的角度粗浅地解释一下匿名管道的工作原理。
现在你只需要知道,pipe命令在内核中创建了一块内存,内核返回了两个文件描述符来让用户操作它,存放于int p[2],其中p[1]为管道的输入端描述符,p[0]为管道的输出端描述符。
下面画图示意管道建立的过程。首先由shell进程调用pipe系统调用,它会返回两个文件描述符,一个代表管道的输出端,另一个代表管道的输入端
调用fork系统调用生成的子进程的文件描述符表与shell进程相同:
然后管道左边的进程,即nm进程,将自己的fd = 1这个描述符重定向到管道的输出端,管道右边的进程,即c++filt进程,将自己的fd = 0这个描述符重定向到管道的输入端,并删除多余的文件描述符表:
然后nm进程的输出就顺利地通过管道作为c++filt进程的输入了:
sys_pipe实现
现在来看匿名管道的实现,参考Linux2.4内核源码,pipe系统调用对应的内核函数为sys_pipe:
asmlinkage int sys_pipe(int a0, int a1, int a2, int a3, int a4, int a5,
struct pt_regs regs)
{
int fd[2];
int error;
error = do_pipe(fd); // 主要工作在do_pipe函数中完成
if (error)
goto out;
(®s)->r20 = fd[1];
error = fd[0];
out:
return error;
}
将do_pipe的一些检查与错误处理去掉,得到下面的一份代码:
int do_pipe(int *fd)
{
struct qstr this;
char name[32];
struct dentry *dentry;
struct inode * inode;
struct file *f1, *f2;
int error;
int i,j;
// 1. 分配两个file结构
f1 = get_empty_filp();
f2 = get_empty_filp();
// 2. 分配一个inode
// 从slab分配器管理的inode_cachep中获得一个inode结构,这只是一个在内存中的结构。分配管道所使用的内存缓冲, 并设置管道特定的数据结构。
inode = get_pipe_inode();
// 3. 分配两个fd
i = get_unused_fd(); // 从fd数组中找到一个空闲下标
j = get_unused_fd(); // 从fd数组中找再到一个空闲下标
// 4. 分配一个dentry
sprintf(name, "[%lu]", inode->i_ino);
this.name = name;
this.len = strlen(name);
this.hash = inode->i_ino;
dentry = d_alloc(pipe_mnt->mnt_sb->s_root, &this); // 分配一个dentry
dentry->d_op = &pipefs_dentry_operations; // 虚拟文件系统的体现
// 5. 将分配的dentry与inode挂上钩
d_add(dentry, inode);
// 6. 配置file结构的属性
f1->f_vfsmnt = f2->f_vfsmnt = mntget(mntget(pipe_mnt)); // 设置pipe文件系统的安装点
f1->f_dentry = f2->f_dentry = dget(dentry); // 设置file结构的dentry指针
// 配置读端file结构
f1->f_pos = f2->f_pos = 0;
f1->f_flags = O_RDONLY;
f1->f_op = &read_pipe_fops;
f1->f_mode = 1;
f1->f_version = 0;
// 配置写端file结构
f2->f_flags = O_WRONLY;
f2->f_op = &write_pipe_fops;
f2->f_mode = 2;
f2->f_version = 0;
// 7. 将fd数组与file结构联系起来
fd_install(i, f1);
fd_install(j, f2);
fd[0] = i;
fd[1] = j;
return 0;
}
上面涉及的数据结构包括 : inode、file_struct、dentry都是文件系统的典型组件,如果你还不熟悉这些概念,那么可以先看看文件系统的部分,或者可以先试着看xv6的文件系统,比之linux简单很多,但是它大体结构是差不多的,下面是xv6文件系统的总图,可以看到与linux的文件系统有着很多相似的概念。
再回到Linux do_pipe()函数中,它会调用get_pipe_inode来获取一个inode节点:
-
在inode_cachep中分配一个inode节点
-
调用pipe_new函数为这个inode节点做定制:
struct inode* pipe_new(struct inode* inode) { unsigned long page; page = __get_free_page(GFP_USER); if (!page) return NULL; inode->i_pipe = kmalloc(sizeof(struct pipe_inode_info), GFP_KERNEL); // 分配pipe_inode_info结构 if (!inode->i_pipe) goto fail_page; init_waitqueue_head(PIPE_WAIT(*inode)); PIPE_BASE(*inode) = (char*) page; // 将缓冲区记录在pipe_inode_info结构中 PIPE_START(*inode) = PIPE_LEN(*inode) = 0; // 目前缓冲区没有数据 PIPE_READERS(*inode) = PIPE_WRITERS(*inode) = 0; // 将管道的读者和写者数量清零 PIPE_WAITING_READERS(*inode) = PIPE_WAITING_WRITERS(*inode) = 0; PIPE_RCOUNTER(*inode) = PIPE_WCOUNTER(*inode) = 1; return inode; }
当inode所代表的对象是一个管道时,inode.i_pipe将指向一个pipe_inode_info结构,该结构就描述了一个管道的特性:
struct inode { // ... struct inode_operations *i_op; struct file_operations *i_fop; /* former ->i_op->default_file_ops */ struct super_block *i_sb; struct pipe_inode_info *i_pipe; // //... } struct pipe_inode_info { wait_queue_head_t wait; // 等待队列 char *base; // 指向缓冲区的起点 unsigned int start; unsigned int readers; // 管道有多少读者 unsigned int writers; // 多少写者 unsigned int waiting_readers; unsigned int waiting_writers; unsigned int r_counter; unsigned int w_counter; };
pipe_new中调用了kmalloc为管道分配了缓冲区,并将其地址赋值给了base指针。
在继续do_pipe函数前,我想谈一下Linux的虚拟文件系统。Linux号称“一切皆文件”,能够做到这一点Linux的虚拟文件系统功不可没。以我的理解,虚拟文件系统的有两部分骨干,一是数据结构,包括inode、file、inode等,一般情况下再简单的文件系统都有类似的数据结构;二是一些“操作接口”,这才是虚拟文件系统的精髓。以file结构为例,它除了存储一些必须信息外,它还存储了一个file_operations结构的指针,而file_operation结构中存储了很多函数指针:
struct file_operations {
struct module *owner;
loff_t (*llseek) (struct file *, loff_t, int);
ssize_t (*read) (struct file *, char *, size_t, loff_t *);
ssize_t (*write) (struct file *, const char *, size_t, loff_t *);
int (*readdir) (struct file *, void *, filldir_t);
unsigned int (*poll) (struct file *, struct poll_table_struct *);
int (*ioctl) (struct inode *, struct file *, unsigned int, unsigned long);
int (*mmap) (struct file *, struct vm_area_struct *);
int (*open) (struct inode *, struct file *);
int (*flush) (struct file *);
int (*release) (struct inode *, struct file *);
int (*fsync) (struct file *, struct dentry *, int datasync);
int (*fasync) (int, struct file *, int);
int (*lock) (struct file *, int, struct file_lock *);
ssize_t (*readv) (struct file *, const struct iovec *, unsigned long, loff_t *);
ssize_t (*writev) (struct file *, const struct iovec *, unsigned long, loff_t *);
};
熟悉的函数有read、write、mmap、llseek等。这就是操作接口, file_operations规定了一个file能够做些什么操作,但没有具体实现它,具体的实现则由各自的不同种类的文件实现它,当然也可以仅仅选择几个函数去实现它(比如管道文件就没有实现mmap函数)这看上去很像面向对象语言的接口与实现的关系。
比如对于管道文件的只读端口,do_pipe会将file.fop设置为read_pipe_fops:
int do_pipe(int *fd)
{
//.....
// 配置读端file结构
f1->f_flags = O_RDONLY;
f1->f_op = &read_pipe_fops;
// ......
}
struct file_operations read_pipe_fops = {
llseek: pipe_lseek,
read: pipe_read,
write: bad_pipe_w,
poll: pipe_poll,
ioctl: pipe_ioctl,
open: pipe_read_open,
release: pipe_read_release,
};
static ssize_t
bad_pipe_w(struct file *filp, const char *buf, size_t count, loff_t *ppos)
{
return -EBADF;
}
对于读端,其write的具体实现只是返回一个错误值,表示不允许读操作。而且管道在概念上是字符流序列,因此它的llseek函数也是非法的:
static loff_t
pipe_lseek(struct file *file, loff_t offset, int orig)
{
return -ESPIPE;
}
对于管道文件的只写端口,又会将file.fop设置为write_pipe_fops。
struct file_operations write_pipe_fops = {
llseek: pipe_lseek,
read: bad_pipe_r,
write: pipe_write,
poll: pipe_poll,
ioctl: pipe_ioctl,
open: pipe_write_open,
release: pipe_write_release,
};
对于ext2文件,这是Linux上最常规的文件,其file.fop设置为ext2_file_operations:
struct file_operations ext2_file_operations = {
llseek: ext2_file_lseek,
read: generic_file_read,
write: generic_file_write,
ioctl: ext2_ioctl,
mmap: generic_file_mmap,
open: ext2_open_file,
release: ext2_release_file,
fsync: ext2_sync_file,
};
虽然,管道、ext文件、设备文件等的底层实现各不相同,但对于应用层,能够只使用fd与几个系统调用将这些对象统统当成文件来操作。以read系统调用为例,它对应的内核函数为sys_read:
asmlinkage ssize_t sys_read(unsigned int fd, char * buf, size_t count)
{
// ...
file = fget(fd);
if (file) {
if (file->f_mode & FMODE_READ) {
// ...
if (!ret) {
ssize_t (*read)(struct file *, char *, size_t, loff_t *);
if (file->f_op && (read = file->f_op->read) != NULL) // 调用fop的read函数
ret = read(file, buf, count, &file->f_pos);
// ...
}
return ret;
}
不难发现,实际上的调用仅为 file->f_op->read(file, buf, count, &file->f_pos),不用去管各个文件类型的不同底层实现。如此就顺利的实现了“一切皆文件的特性”。当然,虚拟文件系统的“接口”远不止这些,dentry、inode甚至是super_block结构中都有一个类似这样的接口指针,它们一起构成了虚拟文件系统对外部文件系统的接口。
好了,到目前do_pipe函数结束位置,内核已经为shell进程建立了相关数据结构,并正确地设置好了具体的操作函数,示意图如下:
管道的读写
管道的读写本质上是消费者生产者模型。
根据上一节关于Linux需文件系统的介绍,当上层调用者对一个管道文件描述符调用read时,会根据file结构中的f_op->read调用pipe_read函数:
pipe_read(struct file *filp, char *buf, size_t count, loff_t *ppos)
{
struct inode *inode = filp->f_dentry->d_inode;
ssize_t size, read, ret;
// ...
/* 内核同步操作*/
ret = -ERESTARTSYS;
if (down_interruptible(PIPE_SEM(*inode)))
goto out_nolock;
// 1. 如果管道缓冲区为空
if (PIPE_EMPTY(*inode)) {
do_more_read:
ret = 0;
if (!PIPE_WRITERS(*inode)) // 如果没有写者,退出循环并返回
goto out;
for (;;) {
PIPE_WAITING_READERS(*inode)++;
pipe_wait(inode); // 本进程陷入阻塞
PIPE_WAITING_READERS(*inode)--;
ret = -ERESTARTSYS;
if (signal_pending(current)) // 如果有信号,则先处理信号,退出循环并返回
goto out;
ret = 0;
if (!PIPE_EMPTY(*inode)) // 检测到管道不为空退出循环,然后读取管道内容
break;
if (!PIPE_WRITERS(*inode)) // 如果没有写者,退出循环并返回
goto out;
}
}
// 2. 拷贝缓冲区中的数据到用户缓冲区
ret = -EFAULT;
while (count > 0 && (size = PIPE_LEN(*inode))) {
char *pipebuf = PIPE_BASE(*inode) + PIPE_START(*inode);
ssize_t chars = PIPE_MAX_RCHUNK(*inode);
if (chars > count)
chars = count;
if (chars > size)
chars = size;
if (copy_to_user(buf, pipebuf, chars))
goto out;
read += chars;
PIPE_START(*inode) += chars;
PIPE_START(*inode) &= (PIPE_SIZE - 1);
PIPE_LEN(*inode) -= chars;
count -= chars;
buf += chars;
}
// 3. 唤醒在管道上睡眠的写者
if (count && PIPE_WAITING_WRITERS(*inode) && !(filp->f_flags & O_NONBLOCK)) {
wake_up_interruptible_sync(PIPE_WAIT(*inode));
if (!PIPE_EMPTY(*inode))
BUG();
goto do_more_read;
}
wake_up_interruptible(PIPE_WAIT(*inode));
ret = read;
out:
up(PIPE_SEM(*inode));
out_nolock:
if (read)
ret = read;
return ret;
}
还是比较简单的,至于管道的写操作,也是不难。需要注意的是如果对一个没有读者的管道进行写操作,那么pipe_write函数就会对本进程发送一个sig_pipe信号:
sigpipe:
if (written)
goto out;
up(PIPE_SEM(*inode));
send_sig(SIGPIPE, current, 0);
当进程收到sig_pipe信号后,默认的行为是终止进程的运行,关于信号的详细原理见上一章。
有名管道 FIFO
匿名管道有着很明显的缺点:匿名管道只能在有亲缘关系的进程之间起作用。有名管道很好地解决了这个问题。
匿名管道的inode只是在slab分配器中分配了一个,它只存在于内存中,且除了shell进程的子进程,其他进程都不能“看到”这个匿名管道;
区别于匿名管道,有名管道的inode是从磁盘中读出来的,这意味着所有的进程都可以在文件系统中“看到”有名管道的inode和文件名,所有的进程都可以使用open打开这个有名管道。初次之外,有名管道与匿名管道就没有区别了,有名管道同样在内存中一个缓冲区,且有名管道的读写函数是与匿名管道一样的。
mknod
那么既然有名管道的inode在磁盘上是有“身份证”的,那么在使用FIFO前,需要先船舰这个FIFO。
shell命令mkfifo
,能够创建一个有名管道文件:
$ mkfifo testfifo
$ ll
total 12
drwxrwxr-x 2 ubuntu ubuntu 4096 Jun 3 20:55 ./
drwxrwxr-x 8 ubuntu ubuntu 4096 Jun 3 20:54 ../
-rw-rw-r-- 1 ubuntu ubuntu 14 Jun 3 21:01 regularfile
prw-rw-r-- 1 ubuntu ubuntu 0 Jun 3 20:54 testfifo|
mkfifo将调用内核底层的sys_mknod函数创建一个有名管道文件,实际上只是创建了一个inode节点:
asmlinkage long sys_mknod(const char * filename, int mode, dev_t dev)
{
// ...
if (!IS_ERR(dentry)) {
switch (mode & S_IFMT) {
case 0: case S_IFREG:
error = vfs_create(nd.dentry->d_inode,dentry,mode);
break;
case S_IFCHR: case S_IFBLK: case S_IFIFO: case S_IFSOCK: // S_FIFO的情况
error = vfs_mknod(nd.dentry->d_inode,dentry,mode,dev);
// ...
return error;
}
vfs_mknod函数也算是虚拟文件系统的一个接口了, 它根据inode.inode_operations中具体的mknod函数在一个具体的文件系统上创建一个有名管道文件:
int vfs_mknod(struct inode *dir, struct dentry *dentry, int mode, dev_t dev)
{
// ...
error = dir->i_op->mknod(dir, dentry, mode, dev);
//..
}
一般Linux的主要文件系统是ext文件系统,我们的管道文件也是建立在ext文件系统上的,所以需要ext文件系统提供的具体mknod函数在其上建立有名管道文件。Linux2.4内核中调用的是ext2_mknod函数:
static int ext2_mknod (struct inode * dir, struct dentry *dentry, int mode, int rdev)
{
struct inode * inode = ext2_new_inode (dir, mode);
// ...
init_special_inode(inode, mode, rdev);
err = ext2_add_entry (dir, dentry->d_name.name, dentry->d_name.len,
inode); // 将新建的节点加进所在目录在磁盘上的目录文件中
if (err)
goto out_no_entry;
mark_inode_dirty(inode); // 将inode标记位脏!
d_instantiate(dentry, inode);// 将inode结构与dentry结构联系起来
// ...
}
可以看到ext2_mknod函数调用ext2_new_inode函数分配一个inode,这个函数不光在内存在中分配了一个inode,同时还修改了磁盘上的相关结构,比如inode_map会有多一个bit被设置为1。
然后调用init_special_inode设置这个inode,我们这里这关注FIFO文件:
void init_special_inode(struct inode *inode, umode_t mode, int rdev)
{
// ...
} else if (S_ISFIFO(mode))
inode->i_fop = &def_fifo_fops;
}
struct file_operations def_fifo_fops = {
open: fifo_open, /* will set read or write pipe_fops */
};
会将inode.i_fop设置为def_fifo_fops,该结构只指定了一个open函数。
ext2_mknod最后调用mark_inode_dirty将这个inode标记为脏,如此一来,该有名管道文件就在磁盘上有了“据点”,所有其他进程都可以在Linux的ext文件系统上“看到”这个有名管道文件。
有名管道的操作
要操作一个FIFO,那么要先调用open函数打开这个有名管道。根据上一节的线索,打开一个管道文件会调用def_fifo_fops结构定义的fifo_open函数:
static int fifo_open(struct inode *inode, struct file *filp)
{
// ...
if (!inode->i_pipe) {
ret = -ENOMEM;
if(!pipe_new(inode)) // 注意pipe_new, 与匿名管道一样,都会分配一个内存缓冲区、pipe_info_struct
goto err_nocleanup;
}
filp->f_version = 0;
switch (filp->f_mode) {
case 1: // 只读模式
filp->f_op = &read_fifo_fops;
// ...
case 2: // 只写
filp->f_op = &write_fifo_fops;
// ...
break;
case 3: // 读写
filp->f_op = &rdwr_fifo_fops;
// ...
}
// ...
}
如上所示,fifo_open函数会先调用pipe_new函数,该函数很熟悉吧?该函数就是上一章匿名管道用来分配内存缓冲区和pipe_info_strut的结构。
然后视上层调用者输入的模式不同,给file结构的f_op指针赋予不同的值:
struct file_operations read_fifo_fops = { // 只读模式
llseek: pipe_lseek,
read: pipe_read,
write: bad_pipe_w,
poll: fifo_poll,
ioctl: pipe_ioctl,
open: pipe_read_open,
release: pipe_read_release,
};
struct file_operations write_fifo_fops = { // 只写模式
llseek: pipe_lseek,
read: bad_pipe_r,
write: pipe_write,
poll: fifo_poll,
ioctl: pipe_ioctl,
open: pipe_write_open,
release: pipe_write_release,
};
struct file_operations rdwr_fifo_fops = {
llseek: pipe_lseek,
read: pipe_read,
write: pipe_write,
poll: fifo_poll,
ioctl: pipe_ioctl,
open: pipe_rdwr_open,
release: pipe_rdwr_release,
};
与上一章匿名管道的file_operations结构对比,可以发现有名和匿名管道除了poll函数的指针,其余函数指针都是相同的。因此有名管道的读写逻辑与匿名管道相同。
SystemV进程间通信
Linux内核为SystemV的IPC提供了一个统一的系统调用:
int syscall(SYS_ipc, unsigned int call, int first,unsigned long second, unsigned long third, void *ptr,long fifth);
这是一个统一接口,能够同时对消息队列、共享内存、信号量进行操作。
其中参数call为具体的操作码:
#define SEMOP 1
#define SEMGET 2
#define SEMCTL 3
#define MSGSND 11
#define MSGRCV 12
#define MSGGET 13
#define MSGCTL 14
#define SHMAT 21
#define SHMDT 22
#define SHMGET 23
#define SHMCTL 24
SEM开头的是信号量相关操作,MSG开头的是消息队列相关,SHM开头的共享内存相关操作。
当然库函数对这个函数做了包装,比如共享内存的常用API为:
-
shmget
-
shmat
-
shmdt
以下参考linux内核2.4版本,与2.6版本相比,几乎没有变化(仅从数据结构来讲)
消息(报文)队列
内核中用来实现消息队列的数据结构及它们之间的关系如下图所示:
- 内核中有一个全局数据结构 msg_ids, msg.ids.entries指向一个ipc_id数组
- ipc_id数组的每一项都是一个kern_ipc_perm指针,指向一个kern_ipc_perm结构,而该结构内嵌在msg_queue结构中。ipc_id数组的索引下标就是标识。
- msg_queue代表了一个报文队列,它的kern_ipc_perm.key就是键,用户可通过键来向内核标识自己要读取\发送的报文队列。msg_queue维护三个链表头
- q_messages: 将报文结构msg_msg依次通过它的m_list成员串联起来。且一个msg_msg结构的大小只有一个页,因此如果报文实际内容多余一个页面,则要msg_msgseg结构分段存放,它们通过next指针相连
- receivers: 如果msg_queue暂时没有消息可获取,则想要接收者进程在本队列阻塞等待。
- senders:如果msg_queue达到最大容量(q_cbytes == q_qbytes),不能再存放新的消息时,那么发送者在本队列等待。
标识相当于文件标识符fd,而键相当于文件名。我们使用键来创建/获取一个msg_queue,之后则使用标识在这个msg_queue中发送和接收消息,这就好比使用文件名来创建/打开一个文件,之后使用fd读写该文件。
简单介绍一下SystemV的消息队列相关API:
-
msgget(key_t key, int msgflg)用户指定一个键值,返回一个标识符(就是上图的ipc_id数组的下标 + 一些额外处理)。如果该键值对应的消息队列未创建,内核会分配一个msg_queue,在ipc_id数组中找到一个空位,使其kern_ipc_perm指针指向msg_queue.kern_ipc_perm。如果该键值对应的消息队列已经创建,则返回这个已经创建队列的标识号。
-
int msgsnd(int msqid, const void *msgp, size_t msgsz, int msgflg);指定一个标识符,向该队列发送报文。如果队列已满,则阻塞等待。
-
msgrcv(int msqid, void *msgp, size_t msgsz, long msgtyp,int msgflg); 指定一个标识符,从该队列接收报文。如果队列为空,则阻塞等待。
共享内存
共享内存的效率比较高,但是它没有同步机制保证线程安全,如有必要,可能还要配合SystemV的信号量使用。
与消息队列类似,共享内存也有一个全局的ipc_ids数组名为shm_ids,每个ipc_id数组的是一个kern_ipc_perm指针。与消息队列不同kern_ipc_perm的宿主不是msg_queue而是shmid_kernel结构。
shmid_kernel结构与msg_queue结构除了都有kern_ipc_perm这一个相似点以外,其余大不相同。shmid_kernel最具特点的是,它拥有一个struct file指针:
这也暗示了了SystemV的共享内存是通过文件映射机制来完成的。事实上,不论是SystemV,或者是Posix,还是直接使用mmap进行共享内存通信,它们的底层机制都是文件映射,Linux内核底层代码的关键函数实现为do_mmap()。不同的进程选择不同虚拟地址,但是调用shmat函数是指定相同的标识号,那么最终两个进程会将其地址映射到同一个文件上。如此,尽管两个进程的虚拟地址不同,但是它们映射的物理地址都是相同的,这些物理页面组成一个文件。此外,与一般的文件不同,共享内存使用的文件在磁盘的位置是swap空间,且该文件在/dev/shm/目录下,该目录通常用来存储暂时文件,当系统重启时,swap_space将被初始化,那么上次建立的共享内存也就消失了。这样的机制符合共享内存的特点。
其中涉及到多个数据结构多个函数,按照我自己的理解,画了下面这张图作为总结.
主要涉及到两大块的内容
- 文件系统: 涉及的数据结构包括strut file ,struct inode, struct dentrt, struct address_space等
- do_mmap函数: 建立用户空间与物理内存的映射
文件系统比较复杂,尤其是linux存在虚文件系统这一额外的层次, 而do_mmap要与内核的内存管理,文件系统两大模块打交道,因此也比较复杂.
感兴趣的可以看看 <linux内核源代码情景分析> 作为参考
简单介绍一下SystemV共享内存相关的API:
- int shmget(key_t key, size_t size, int shmflg) : 获取 \ 开辟一个共享内存,返回其标识(即内核数据结构shm_ids所指向数组的数组下标, 当然还要加上一些额外处理,防止重复)
- void *shmat(int shmid, const void *shmaddr, int shmflg) : 将本进程的虚拟地址shmaddr映射到内核所维护的共享内存中(即一个文件在内存的缓存)
- shmdt : 消除映射
man page上有一个使用示例.
信号量
SystemV的信号量与一般意义上的信号量有较大不同,最大的区别是SystemV的信号量其实是一个信号量的集合,用户可通过对应的系统调用对这一信号量的集合做原子性的修改。
关于信号量的图我就不自己画了,截一张《深入Linux内核架构》的图片:
可以看到,同样有一个全局数据结构ipc_ipds管理信号量,其中sem_base指针指向一个由struct sem结构组成的数组,该数组就是信号量的一个集合。那些被信号量操作阻塞的进程则在sem_pending链表中等待。
POSIX进程间通信
POSIX也规定了三个标准的进程间通信API,包括信号量、共享内存、信号量(匿名\有名)。除了匿名信号量外,POSIX所规定的3套API都比较统一。以XXX_open为例:
- mq_open
- sem_open (和sem_init, 前者是有名信号量,后者为无名信号量)
- shm_open
POSIX信号量
POSIX信号量有两种,一为有名信号量,而为无名信号量。
其中无名信号量多用于线程同步(当然也可以用于进程同步),有名信号量是依靠文件系统实现的,多用于进程间通信(当然也可以用于进程间通信)。
参考源码为glibc 2.25
sem_init
该函数用来初始化(不是创建是初始化)无名信号,其声明如下:
int sem_init(sem_t *sem, int pshared, unsigned int value);
sem_t* 指针指向内存中的sem_t结构体,pshared参数指定该信号量是被一个进程间的多个线程共享,还是被多个进程共享。也就是说pshared暗示了该匿名信号量是用来进行进程通信还是线程通信。pshared的值同时也指定了sem_t结构体的创建方式:
- 如果信号量用于线程间通信,那么sem_t结构体可以在全局变量区声明定义,然后调用sem_init进行初始化即可,因为同一进程的多个线程的地址空间是一样的。
- 如果信号量用于进程间通信,那么sem_t结构体必须创建于共享内存中,用户可以通过mmap等系统调用创建共享内存,然后调用sem_init时应该传入这个共享内存的虚拟地址。
形参则value指定信号量的初始值。
glibc源码中,__new_sem_init是sem_init函数的真实函数名:
versioned_symbol (libpthread, __new_sem_init, sem_init, GLIBC_2_1);
int
__new_sem_init (sem_t *sem, int pshared, unsigned int value)
{
// ...
pshared = pshared != 0 ? PTHREAD_PROCESS_SHARED : PTHREAD_PROCESS_PRIVATE;
int err = futex_supports_pshared (pshared);
/* Map to the internal type. */
struct new_sem *isem = (struct new_sem *) sem;
/* Use the values the caller provided. */
#if __HAVE_64B_ATOMICS
isem->data = value;
#else
isem->value = value << SEM_VALUE_SHIFT;
/* pad is used as a mutex on pre-v9 sparc and ignored otherwise. */
isem->pad = 0;
isem->nwaiters = 0;
#endif
isem->private = (pshared == PTHREAD_PROCESS_PRIVATE
? FUTEX_PRIVATE : FUTEX_SHARED);
return 0;
}
主要就是对sem_t结构中的各种值进行初始化。其中会根据pshared的值设定private属性的值,你可以看到FUTEX_PRIVATE、FUTEX_SHARED等宏变量,这就暗示了信号量是通过futex实现的,futex也有类似于信号量的pshared的属性,表明它是用来进程间通信还是线程间通信(具体可以参考我的另一篇博客)
无名信号量初始化完成之后,就可以调用sem_wait、sem_post等函数进行操作了,这和有名信号量是一样的。
接下来先看有名信号量的创建。
sem_open
sem_open打开或者创建(包括了初始化步骤)一个有名信号量,成功创建后将会在/dev/shm/ 中出现一个新的文件,这个文件就是不同进程进行通信的桥梁, 而且通过路径名可以猜到有名信号是借助共享内存来实现的。
sem_t *
sem_open (const char *name, int oflag, ...)
{
int fd;
sem_t *result;
// 执行很多检查
SHM_GET_NAME (EINVAL, SEM_FAILED, SEM_SHM_PREFIX); // 对name进行处理,添加/dev/name前缀
// ...
/* 如果原本存在信号量文件,则仅仅打开 */
if ((oflag & O_CREAT) == 0 || (oflag & O_EXCL) == 0)
{
try_again:
fd = __libc_open (shm_name,
(oflag & ~(O_CREAT|O_ACCMODE)) | O_NOFOLLOW | O_RDWR); // 直接打开文件
// ... 一些检查
}
else
{
/* 如果信号量文件并不存在,则先创建 */
char *tmpfname;
mode_t mode;
// ... 对flag中的一系列参数进行检查并定制化信号量
// 初始化文件的内容,也就是信号量的内容
union
{
sem_t initsem;
struct new_sem newsem;
} sem;
sem.newsem.data = value;
/* !!!! This always is a shared semaphore !!!! */
sem.newsem.private = FUTEX_SHARED;
/* Initialize the remaining bytes as well. */
memset ((char *) &sem.initsem + sizeof (struct new_sem), '\0',
sizeof (sem_t) - sizeof (struct new_sem));
// ...
#define NRETRIES 50
while (1)
{
// ...
/* 打开刚刚创建的文件*/
fd = __libc_open (tmpfname, O_RDWR | O_CREAT | O_EXCL, mode);
// ...
}
// 向文件写入信号量的内容,并map到进程的地址空间
if (TEMP_FAILURE_RETRY (__libc_write (fd, &sem.initsem, sizeof (sem_t)))
== sizeof (sem_t)
/* Map the sem_t structure from the file. */
&& (result = (sem_t *) mmap (NULL, sizeof (sem_t),
PROT_READ | PROT_WRITE, MAP_SHARED,
fd, 0)) != MAP_FAILED)
{
// ...
return result;
}
# define SHMDIR ("/dev/shm/")
这个函数确实是比较长的,但总体来说就一句话: 在/dev/shm目录下创建一个共享文件最为信号量的载体,并map到进程的地址空间中。下面来捋一捋这个函数:
- 调用SHM_GET_NAME宏,对文件名进行处理,主要是会另外申明一个shm_name的字符串,并加上/dev/shm的前缀
- 下面按文件的存在与否分成两个分支
- 如果共享文件不存在,则直接打开
- 如果共享文件不存在,则首先创建这个文件,并设置信号量的初始值。注意private属性被设置为FUTEX_SHARED。这不代表有名信号量只能用在进程同步,它依然可以用在线程同步中,但信号量是依靠futex实现的,futex对线程同步有一些额外的优化,因此在线程同步中使用有名信号量稍微显得“划不来”。
- 最后将文件映射到进程的地址空间。
从上面的源码可以看出,有名信号于匿名信号的一大区别就是有名信号依赖于操作系统提供的文件相关的系统调用,磁盘上的文件是有名信号的载体,各个进程通过sem_open函数打开同一个文件,并且将进一步调用mmap,自然而然地,不同进程会将同一个信号量映射到自己的地址空间中。
sem_wait
glibc源码中,__new_sem_wait是sem_wait函数的真实函数名:
int
__new_sem_wait (sem_t *sem)
{
__pthread_testcancel ();
if (__new_sem_wait_fast ((struct new_sem *) sem, 0) == 0)
return 0;
else
return __new_sem_wait_slow((struct new_sem *) sem, NULL);
}
versioned_symbol (libpthread, __new_sem_wait, sem_wait, GLIBC_2_1);
可以看到它仅仅是对两个函数的调用__new_sem_wait_fast、__new_sem_wait_slow。
__new_sem_wait_fast将使用原子指令比较sem_t的value值,如果没有发生竞争则直接return0,如果发生了竞争则接着调用__new_sem_wait_slow, 而后者则会进行futex的系统调用进入内核将本线程阻塞。
这与pthread_mutex_t的futex机制如出一辙,具体可以参考我的另一篇博客。
最后不用细说,你也能菜刀sem_post也与futex机制类似,它只会在发生竞争时才进行futex系统调用进入内核,唤醒陷入阻塞的进程。
POSIX共享内存
POSIX共享内存在本质上与System V的共享内存机制本质上没有区别,它们都借助了文件系统实现了自己的核心功能。只不过POSIX共享内存使用文件名来标识一个共享体,更加“名目张胆”地寻求文件系统的帮助,而System V则使用一个键值来标识一个共享体,它对文件系统的依赖则显得比较隐晦。但正因如此,POSIX共享内存比System V共享内存更灵活,比如POSIX共享内存机制可以通过ftruncate等文件操作调整共享内存的大小,但是System V共享内存的容量一经确定,就无法改变。
shm_open
glibc的shm_open实现非常简单 :
int
shm_open (const char *name, int oflag, mode_t mode)
{
SHM_GET_NAME (EINVAL, -1, ""); // 给name加上前缀 “/dev/shm/”
int state;
int fd = open (shm_name, oflag, mode);
// ... 一些检查以及设置FD_CLOEXEC
return fd;
}
事实上就做了两件事:
- 将用户提供的文件名加上前缀 “/dev/shm/”
- 创建/打开这个文件
最后返回这个共享文件的文件描述符。用户在调用shm_open后,需要进一步调用mmap将文件内容映射至进程地址空间,这与system V的shmat不同,后者不需要用户再次调用mmap,因为它已经集成了类似的操作。
在内核实现上,mmap系统调用与PageCahe是共享内存的关键的技术,后面也会整理这些内容,这是linux内核中比较难也很有趣的部分。
ptrace
待补充
socket
待补充
参考资料
- 《linux内核源代码情景分析》
- 《深入linux内核架构》
- Linux2.4
- Linux2.6.32.10
- glibc-2.37
- xv6(x86版本)