kk Blog —— 通用基础


date [-d @int|str] [+%s|"+%F %T"]
netstat -ltunp
sar -n DEV 1

linux signal 处理

原文

贴一部分:

总结

信号分成两种:
regularsignal( 非实时信号 ), 对应的编码值为 [1,31]
real timesignal 对应的编码值为 [32,64]

编码为 0 的信号 不是有效信号,只用于检查是当前进程否有发送信号的 权限 ,并不真正发送。

线程会有自己的悬挂信号队列 , 并且线程组也有一个信号悬挂队列 . 信号悬挂队列保存 task 实例接收到的信号 , 只有当该信号被处理后它才会从悬挂队列中卸下 .

信号悬挂队列还有一个对应的阻塞信号集合 , 当一个信号在阻塞信号集合中时 ,task 不会处理该被阻塞的信号 ( 但是该信号依旧在悬挂队列中 ). 当阻塞取消时 , 它会被处理 .

对一个信号 , 要三种处理方式 :

忽略该信号 ;
采用默认方式处理 ( 调用系统指定的信号处理函数 );
使用用户指定的方式处理 ( 调用用户指定的信号处理函数 ).

对于某些信号只能采用默认的方式处理 (eg:SIGKILL,SIGSTOP).
信号处理可以分成两个阶段 : 信号产生并通知到接收方 (generation), 接收方进行处理 (deliver) ………

简介

Unix 为了允许用户态进程之间的通信而引入signal. 此外, 内核使用signal 给进程通知系统事件.近30 年来,signal 只有很小的变化 . 以下我们先介绍linuxkernel 如何处理signal, 然后讨论允许进程间 exchange 信号的系统调用.

The Role of Signals

signal 是一种可以发送给一个进程或一组进程的短消息( 或者说是信号 , 但是这么容易和信号量混淆). 这种消息通常只是一个整数 , 而不包含额外的参数 .
linux 提供了很多种signal, 这些signal 通过宏来标识( 这个宏作为这个信号的名字). 并且这些宏的名字的开头是SIG.eg: 宏SIGCHLD , 它对应的整数值为17, 用来表示子进程结束时给父进程发送的消息 ( 即当子进程结束时应该向父进程发送标识符为17 的signal/ 消息/ 信号) .宏SIGSEGV, 它对应的整数值为11, 当进程引用一个无效的物理地址时( 内核) 会向进程发送标识符为11 的signal/ 消息/ 信号 ( 参考linux 内存管理的页错误异常处理程序, 以及linux 中断处理).
信号有两个目的:
1. 使一个进程意识到一个特殊事件发生了( 不同的事件用不同的signal 标识) 2. 并使目标进程进行相应处理(eg: 执行的信号处理函数 , signalhandler). 相应的处理也可以是忽略它 .

当然 , 这两个目的不是互斥的 , 因为通常一个进程意识到一个事件发生后就会执行该事件相应的处理函数 .

下表是linux2.6 在80x86 上的前31 个signals 及其相关说明 . 这些信号中有些是体系结构相关的(eg:SIGCHLD,SIGSTOP), 有些则专门了某些体系结构才存在的(eg:SIGSTKFLT)( 可以参考中断处理 , 里面也列出了一些异常对应的signal).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
The first 31 signals in Linux/i386
Signal name
Default action
Comment
POSIX
1
SIGHUP
Terminate
Hang up controlling terminal or process
Yes
2
SIGINT
Terminate
Interrupt from keyboard
Yes
3
SIGQUIT
Dump
Quit from keyboard
Yes
4
SIGILL
Dump
Illegal instruction
Yes
5
SIGTRAP
Dump
Breakpoint for debugging
No
6
SIGABRT
Dump
Abnormal termination
Yes
6
SIGIOT
Dump
Equivalent to SIGABRT
No
7
SIGBUS
Dump
Bus error
No
8
SIGFPE
Dump
Floating-point exception
Yes
9
SIGKILL
Terminate
Forced-process termination
Yes
10
SIGUSR1
Terminate
Available to processes
Yes
11
SIGSEGV
Dump
Invalid memory reference
Yes
12
SIGUSR2
Terminate
Available to processes
Yes
13
SIGPIPE
Terminate
Write to pipe with no readers
Yes
14
SIGALRM
Terminate
Real-timerclock
Yes
15
SIGTERM
Terminate
Process termination
Yes
16
SIGSTKFLT
Terminate
Coprocessor stack error
No
17
SIGCHLD
Ignore
Child process stopped or terminated, or got signal if traced
Yes
18
SIGCONT
Continue
Resume execution, if stopped
Yes
19
SIGSTOP
Stop
Stop process execution
Yes
20
SIGTSTP
Stop
Stop process issued from tty
Yes
21
SIGTTIN
Stop
Background process requires input
Yes
22
SIGTTOU
Stop
Background process requires output
Yes
23
SIGURG
Ignore
Urgent condition on socket
No
24
SIGXCPU
Dump
CPU time limit exceeded
No
25
SIGXFSZ
Dump
File size limit exceeded
No
26
SIGVTALRM
Terminate
Virtual timer clock
No
27
SIGPROF
Terminate
Profile timer clock
No
28
SIGWINCH
Ignore
Window resizing
No
29
SIGIO
Terminate
I/O now possible
No
29
SIGPOLL
Terminate
Equivalent to SIGIO
No
30
SIGPWR
Terminate
Power supply failure
No
31
SIGSYS
Dump
Bad system call
No
31
SIGUNUSED
Dump
Equivalent to SIGSYS
No

上述signal 称为regularsignal . 除此之外,POSIX 还引入了另外一类singal 即real-timesignal . real timesignal 的标识符的值从32 到64. 它们与reagularsignal 的区别在于每一次发送的real timesignal 都会被加入悬挂信号队列,所以多次发送的real timesignal 会被缓存起来( 而不会导致后面的被忽略掉) . 而同一种( 即标识符一样)regularsignal 不会被缓存,即如果同一个signal 被发送多次 , 它们只有一个会被放入接受进程的悬挂队列 .

虽然linux kernel 并没有使用real timesignal. 但是它也( 通过特殊的系统调用) 支持posix定义的realtime signal.

有很多系统调用可以给进程发送singal, 也有很多系统调可以指定进程在接收某一个signal 时应该如何响应( 即实行哪一个函数). 下表给出了这类系统调用:( 关于这些系统调用的更多信息参考下文)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
System call
Description
kill( )
Send a signal to a thread group
tkill( )
Send a signal to a process
tgkill( )
Send a signal to a process in a specific thread group
sigaction( )
Change the action associated with a signal
signal( )
Similar to sigaction( )
sigpending( )
Check whether there are pending signals
sigprocmask( )
Modify the set of blocked signals
sigsuspend( )
Wait for a signal
rt_sigaction( )
Change the action associated with a real-time signal
rt_sigpending( )
Check whether there are pending real-time signals
rt_sigprocmask( )
Modify the set of blocked real-time signals
rt_sigqueueinfo( )
Send a real-time signal to a thread group
rt_sigsuspend( )
Wait for a real-time signal
rt_sigtimedwait( )
Similar to rt_sigsuspend( )

signal 可能在任意时候被发送给一个状态未知的进程 . 当信号被发送给一个当前并不正在执行的进程时, 内核必须把先把该信号保存直到该进程恢复执行.(to do ???????) 被阻塞的信号尽管会被加入进程的悬挂信号队列 , 但是在其被解除阻塞之前不会被处理(deliver),Blockinga signal (described later) requires that delivery of the signal beheld off until it is later unblocked,which acer s the problemof signals being raised before they can be delivered.

内核把信号传送分成两个阶段:

signalgeneration: 内核更新信号的目的进程的相关数据结构 , 这样该进程就能知道它接收到了一个信号. 觉得称为收到信号阶段更恰当. 这个generation 翻译成目的进程接收也不错 .

signaldelivery(): 内核强制目的进程处理接收到的信号,这主要是通过修改进程的执行状态或者在目的进程中执行信号处理函数来实现的 . 觉得称为处理收到的信号阶段更恰当 . diliver 这里翻译成处理更恰当 . deliver 的翻译: 有很多个 , 估计翻译成incomputing 比较合理

一个genearatedsignal 最多只能deliver 一次( 即一个信号最多只会被处理一次) . signal 是可消耗资源 , 一旦一个signal 被deliver, 那么所有进程对它的引用都会被取消 . 已经产生但是还未被处理(deliver) 的信号称为pendingsignal ( 悬挂信号). 对于regularsignal, 在某一个时刻 , 一种signal 在一个进程中只能有一个实例( 因为进程没有用队列缓存其收到的signal) . 因为有31 种regualarsignal , 所以一个进程某一个时刻可以有31 个各类signal 的实例. 此外因为linux 进程对realtimesignal 采用不同的处理方式, 它会保存接收到的realtimesignal 的实例 , 所以可以同时有很多同种signal 的实例 .

问题: 不同种类的信号的优先级( 从值较小的开始处理) .

一般而言 , 一个信号可能会被悬挂很长是时间( 即一个进程收到一个信号后 , 该信号有可能在该进程里很久 , 因为进程没空来处理它), 主要有如下因素:
1. 信号通常被当前进程处理 . Signalsare usually delivered only to the currently running process (thatis, to the current process).
2. 某种类型的信号可能被本进程阻塞. 只有当其被取消阻塞好才会被处理 .
3. 当一个进程执行某一种信号的处理函数时 , 一般会自动阻塞这种信号 , 等处理完毕后才会取消阻塞 . 这意味着一个信号处理函数不会被同种信号阻塞 .

尽管信号在概念上很直观 , 但是内核的实现却相当复杂. 内核必须:
1. 记录一个进程阻塞了哪些信号
2. 当从核心态切换到用户态时 , 检查进程是否接受到了signal.( 几乎每一次时钟中断都要干这样的事 , 费时吗?).
3. 检查信号是否可以被忽略. 当如下条件均满足时则可被忽略:
1). 目标进程未被其它进程traced( 即PT_PTRACED==0). 但一个被traced 的进程收到一个信号时 , 内核停止目标线程 , 并且给tracing 进程发送信号SIGCHLD.tracing 进程可能会通过SIGCONT来恢复traced 进程的执行
2). 目标进程未阻塞该信号 .
3). 信号正被目标进程忽略( 或者由于忽略是显式指定的或者由于忽略是默认操作).
4. 处理信号 . 这可能需要切换到信号处理函数

此外, linux 还需要处理BSD, SystemV 中signal 语义的差异性 . 另外 , 还需要遵守POSIX 的定义 .

处理信号的方式 (Actions Performed uponDelivering a Signal)

一个进程可以采用三中方式来响应它接收到的信号:
1.(ignore) 显示忽略该信号
2.(default) 调用默认的函数来响应该信号( 这些默认的函数由内核定义) , 一般这些默认的函数都分成如下几种( 采用哪一种取决于信号的类型 , 参考前面的表格):

1
2
3
4
5
Terminate: The process is terminated(killed) 
Dump: The process is terminated (killed) and a core file containingits execution context is created, if possible; this file may beused for debug purposes. 
Ignore:The signal is ignored. 
Stop:The process is stopped, i.e., put in the TASK_STOPPEDstate. 
Continue:If the process was stopped (TASK_STOPPED), it is put intothe TASK_RUNNING state.

3.(catch) 调用相应的信号处理函数 ( 这个信号处理函数通常是程序员在运行时指定的). 这意味着进程需要在执行时显式地指明它需要catch 哪一种信号. 并且指明其处理函数 . catch 是一种主动处理的措施 .

注意上述的三个处理方式被标识为:ignore, default,catch. 这三个处理方式以后会通过这三个标识符引用 .

注意阻塞一个信号和忽略一个信号是不同 , 一个信号被阻塞是就当前不会被处理 , 即一个信号只有在解除阻塞后才会被处理 . 忽略一个信号是指采用忽略的方式来处理该信号( 即对该信号的处理方式就是什么也不做) .

SIGKILL 和SIGSTOP 这两个信号不能忽略 , 不能阻塞 , 不能使用用户定义的函数(caught) . 所以总是执行它们的默认行为 . 所以 , 它们允许具有恰当特权级的用户杀死别的进程, 而不必在意被杀进程的防护措施 ( 这样就允许高特权级用户杀死低特权级的用户占用大量cpu 的时间) .

注: 有两个特殊情况. 第一 , 任意进程都不能给进程0( 即swapper 进程) 发信号 . 第二 , 发给进程1 的信号都会被丢弃(discarded), 除非它们被catch. 所以进程 0 不会死亡, 进程1 仅在int 程序结束时死亡 .

一个信号对一个进程而言是致命的(fatal) , 当前仅当该信号导致内核杀死该进程 . 所以,SIGKILL 总是致命的. 此外 , 如果一个进程对一个信号的默认行为是terminate 并且该进程没有catch 该信号 , 那么该信号对这个进程而言也是致命的 . 注意 , 在catch 情况下 , 如果一个进程的信号处理函数自己杀死了该进程 , 那么该信号对这个进程而言不是致命的 , 因为不是内核杀死该进程而是进程的信号处理函数自己杀死了该进程.

POSIX 信号以及多线程程序

POSIX1003.1 标准对多线程程序的信号处理有更加严格的要求:
( 由于linux 采用轻量级进程来实现线程 , 所以对linux 的实现也会有影响)
1. 多线程程序的所有线程应该共享信号处理函数 , 但是每一个线程必须有自己的maskof pending and blocked signals
2. POSIX 接口kill( ), sigqueue() 必须把信号发给线程组 , 而不是指定线程. 另外内核产生的SIGCHLD,SIGINT, or SIGQUIT 也必须发给线程组 .
3. 线程组中只有有一个线程来处理(deliver) 的共享的信号就可以了 . 下问介绍如何选择这个线程 .
4. 如果线程组收到一个致命的信号 , 内核要杀死线程组的所有线程, 而不是仅仅处理该信号的线程 .

为了遵从POSIX 标准,linux2.6 使用轻量级进程实现线程组.

下文中 , 线程组表示OS 概念中的进程, 而线程表示linux 的轻量级进程. 进程也( 更多地时候)表示linux 的轻量级进程 . 另外每一个线程有一个私有的悬挂信号列表 , 线程组共享一个悬挂信号列表 .

pipe 函数

pipe我们用中文叫做管道。

函数简介

所需头文件 #include<unistd.h>
函数原型 int pipe(int fd[2])
函数传入值 fd[2]:管道的两个文件描述符,之后就是可以直接操作者两个文件描述符
返回值 成功 0 失败 -1什么是管道

管道是Linux 支持的最初Unix IPC形式之一,具有以下特点:

管道是半双工的,数据只能向一个方向流动;需要双方通信时,需要建立起两个管道; 只能用于父子进程或者兄弟进程之间(具有亲缘关系的进程); 单独构成一种独立的文件系统:管道对于管道两端的进程而言,就是一个文件,但它不是普通的文件,它不属于某种文件系统,而是自立门户,单独构成一种文件系 统,并且只存在与内存中。 数据的读出和写入:一个进程向管道中写的内容被管道另一端的进程读出。写入的内容每次都添加在管道缓冲区的末尾,并且每次都是从缓冲区的头部读出数据。

管道的创建
1
2
#include <unistd.h>
int pipe(int fd[2])

该函数创建的管道的两端处于一个进程中间,在实际应用中没有太大意义,因此,一个进程在由 pipe()创建管道后,一般再fork一个子进程,然后通过管道实现父子进程间的通信(因此也不难推出,只要两个进程中存在亲缘关系,这里的亲缘关系指 的是具有共同的祖先,都可以采用管道方式来进行通信)。管道的读写规则 管道两端可 分别用描述字fd[0]以及fd[1]来描述,需要注意的是,管道的两端是固定了任务的。即一端只能用于读,由描述字fd[0]表示,称其为管道读端;另 一端则只能用于写,由描述字fd[1]来表示,称其为管道写端。如果试图从管道写端读取数据,或者向管道读端写入数据都将导致错误发生。一般文件的I/O 函数都可以用于管道,如close、read、write等等。

从管道中读取数据:

如果管道的写端不存在,则认为已经读到了数据的末尾,读函数返回的读出字节数为0; 当管道的写端存在时,如果请求的字节数目大于PIPE_BUF,则返回管道中现有的数据字节数,如果请求的字节数目不大于PIPE_BUF,则返回管道中 现有数据字节数(此时,管道中数据量小于请求的数据量);或者返回请求的字节数(此时,管道中数据量不小于请求的数据量)。注:(PIPE_BUF在 include/linux/limits.h中定义,不同的内核版本可能会有所不同。Posix.1要求PIPE_BUF至少为512字节,red hat 7.2中为4096)。

关于管道的读规则验证:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
/**************
* readtest.c *
**************/
#include <unistd.h>
#include <sys/types.h>
#include <errno.h>
main()
{
	int pipe_fd[2];
	pid_t pid;
	char r_buf[100];
	char w_buf[4];
	char* p_wbuf;
	int r_num;
	int cmd;
	memset(r_buf,0,sizeof(r_buf));
	memset(w_buf,0,sizeof(r_buf));
	p_wbuf=w_buf;
	if(pipe(pipe_fd)<0)
	{
		printf("pipe create error ");
		return -1;
	}
	if((pid=fork())==0)
	{
		printf(" ");
		close(pipe_fd[1]);
		sleep(3);//确保父进程关闭写端
		r_num=read(pipe_fd[0],r_buf,100);
		printf( "read num is %d the data read from the pipe is %d ",r_num,atoi(r_buf));
		close(pipe_fd[0]);
		exit();
	}
	else if(pid>0)
	{
		close(pipe_fd[0]);//read
		strcpy(w_buf,"111");
		if(write(pipe_fd[1],w_buf,4)!=-1)
			printf("parent write over ");
		close(pipe_fd[1]);//write
		printf("parent close fd[1] over ");
		sleep(10);
	}
}
/**************************************************
* 程序输出结果:
* parent write over
* parent close fd[1] over
* read num is 4 the data read from the pipe is 111
* 附加结论:
* 管道写端关闭后,写入的数据将一直存在,直到读出为止.
****************************************************/
向管道中写入数据:

向管道中写入数据时,linux将不保证写入的原子性,管道缓冲区一有空闲区域,写进程就会试 图向管道写入数据。如果读进程不读走管道缓冲区中的数据,那么写操作将一直阻塞。 注:只有在管道的读端存在时,向管道中写入数据才有意义。否则,向管道中写入数据的进程将收到 内核传来的SIFPIPE信号,应用程序可以处理该信号,也可以忽略(默认动作则是应用程序终止)。

对管道的写规则的验证1:写端对读端存在的依赖性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#include <unistd.h>
#include <sys/types.h>
main()
{
	int pipe_fd[2];
	pid_t pid;
	char r_buf[4];
	char* w_buf;
	int writenum;
	int cmd;
	memset(r_buf,0,sizeof(r_buf));
	if(pipe(pipe_fd)<0)
	{
		printf("pipe create error ");
		return -1;
	}
	if((pid=fork())==0)
	{
		close(pipe_fd[0]);
		close(pipe_fd[1]);
		sleep(10);
		exit();
	}
	else if(pid>0)
	{
		sleep(1); //等待子进程完成关闭读端的操作
		close(pipe_fd[0]);//write
		w_buf="111";
		if((writenum=write(pipe_fd[1],w_buf,4))==-1)
			printf("write to pipe error ");
		else
			printf("the bytes write to pipe is %d ", writenum);
		close(pipe_fd[1]);
	}
}

则输出结果为: Broken pipe,原因就是该管道以及它的所有fork()产物的读端都已经被关闭。如果在父进程中保留读端,即在写完pipe后,再关闭父进程的读端,也会正常 写入pipe,读者可自己验证一下该结论。因此,在向管道写入数据时,至少应该存在某一个进程,其中管道读端没有被关闭,否则就会出现上述错误(管道断 裂,进程收到了SIGPIPE信号,默认动作是进程终止)

对管道的写规则的验证2:linux不保证写管道的原子性验证

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
#include <unistd.h>
#include <sys/types.h>
#include <errno.h>
main(int argc,char**argv)
{
	int pipe_fd[2];
	pid_t pid;
	char r_buf[4096];
	char w_buf[4096*2];
	int writenum;
	int rnum;
	memset(r_buf,0,sizeof(r_buf));
	if(pipe(pipe_fd)<0)
	{
		printf("pipe create error ");
		return -1;
	}
	if((pid=fork())==0)
	{
		close(pipe_fd[1]);
		while(1)
		{
			sleep(1);
			rnum=read(pipe_fd[0],r_buf,1000);
			printf("child: readnum is %d ",rnum);
		}
		close(pipe_fd[0]);
		exit();
	}
	else if(pid>0)
	{
		close(pipe_fd[0]);//write
		memset(r_buf,0,sizeof(r_buf));
		if((writenum=write(pipe_fd[1],w_buf,1024))==-1)
			printf("write to pipe error ");
		else
			printf("the bytes write to pipe is %d ", writenum);
		writenum=write(pipe_fd[1],w_buf,4096);
		close(pipe_fd[1]);
	}
}
输出结果:
the bytes write to pipe 1000
the bytes write to pipe 1000 //注意,此行输出说明了写入的非原子性
the bytes write to pipe 1000
the bytes write to pipe 1000
the bytes write to pipe 1000
the bytes write to pipe 120 //注意,此行输出说明了写入的非原子性
the bytes write to pipe 0
the bytes write to pipe 0
......

结论:

写入数目小于4096时写入是非原子的!
如果把父进程中的两次写入字节数都改为5000,则很容易得出下面结论:
写入管道的数据量大于4096字节时,缓冲区的空闲空间将被写入数据(补齐),直到写完所有数 据为止,如果没有进程读数据,则一直阻塞。管道应用实例

实例一:用于shell

管道可用于输入输出重定向,它将一个命令的输出直接定向到另一个命令的输入。比如,当在某个 shell程序(Bourne shell或C shell等)键入who│wc -l后,相应shell程序将创建who以及wc两个进程和这两个进程间的管道。考虑下面的命令行:

1
2
3
4
5
6
7
8
$kill -l 运行结果见 附一。
$kill -l | grep SIGRTMIN 
运行结果如下:
30) SIGPWR 31) SIGSYS 32) SIGRTMIN 33) SIGRTMIN+1
34) SIGRTMIN+2 35) SIGRTMIN+3 36) SIGRTMIN+4 37) SIGRTMIN+5
38) SIGRTMIN+6 39) SIGRTMIN+7 40) SIGRTMIN+8 41) SIGRTMIN+9
42) SIGRTMIN+10 43) SIGRTMIN+11 44) SIGRTMIN+12 45) SIGRTMIN+13
46) SIGRTMIN+14 47) SIGRTMIN+15 48) SIGRTMAX-15 49) SIGRTMAX-14
实例二:用于具有亲缘关系的进程间通信

下面例子给出了管道的具体应用,父进程通过管道发送一些命令给子进程,子进程解析命令,并根据 命令作相应处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
#include <unistd.h>
#include <sys/types.h>
main()
{
	int pipe_fd[2];
	pid_t pid;
	char r_buf[4];
	char** w_buf[256];
	int childexit=0;
	int i;
	int cmd;
	memset(r_buf,0,sizeof(r_buf));
	if(pipe(pipe_fd)<0)
	{
		printf("pipe create error ");
		return -1;
	}
	if((pid=fork())==0)
		//子进程:解析从管道中获取的命令,并作相应的处理
	{
		printf(" ");
		close(pipe_fd[1]);
		sleep(2);
		while(!childexit)
		{
			read(pipe_fd[0],r_buf,4);
			cmd=atoi(r_buf);
			if(cmd==0)
			{
				printf("child: receive command from parent over now child process exit ");
				childexit=1;
			}
			else if(handle_cmd(cmd)!=0)
				return;
			sleep(1);
		}
		close(pipe_fd[0]);
		exit();
	}
	else if(pid>0)
	//parent: send commands to child
	{
		close(pipe_fd[0]);
		w_buf[0]="003";
		w_buf[1]="005";
		w_buf[2]="777";
		w_buf[3]="000";
		for(i=0;i<4;i++)
			write(pipe_fd[1],w_buf[i],4);
		close(pipe_fd[1]);
	}
}
//下面是子进程的命令处理函数(特定于应用):
int handle_cmd(int cmd)
{
	if((cmd<0)||(cmd>256))
	//suppose child only support 256 commands
	{
		printf("child: invalid command ");
		return -1;
	}
	printf("child: the cmd from parent is %d ", cmd);
	return 0;
}

管道的局限性

管道的 主要局限性正体现在它的特点上:
只支持单向数据流; 只能用于具有亲缘关系的进程之间; 没有名字; 管道的缓冲区是有限的(管道制存在于内存中,在管道创建时,为缓冲区分配一个页面大小);
管道所传送的是无格式字节流,这就要求管道的读出方和写入方必须事先约定好数据的格式,比如多 少字节算作一个消息(或命令、或记录)等等;

Linux管道的实现机制

在Linux中,管道是一种使用非常频繁的通信机制。从本质上说,管道也是一种文件,但它又和 一般的文件有所不同,管道可以克服使用文件进行通信的两个问题,具体表现为: 限制管道的大小。实际上,管道是一个固定大小的缓冲区。在Linux中,该缓冲区的大小为1 页,即4K字节,使得它的大小不象文件那样不加检验地增长。使用单个固定缓冲区也会带来问题,比如在写管道时可能变满,当这种情况发生时,随后对管道的 write()调用将默认地被阻塞,等待某些数据被读取,以便腾出足够的空间供write()调用写。

读取进程也可能工作得比写进程快。当所有当前进程数据已被读取时,管道变空。当这种情况发生 时,一个随后的read()调用将默认地被阻塞,等待某些数据被写入,这解决了read()调用返回文件结束的问题。

注意:从管道读数据是一次性操作,数据一旦被读,它就从管道中被抛弃,释放空间 以便写更多的数据。

1. 管道的结构

在 Linux 中,管道的实现并没有使用专门的数据结构,而是借助了文件系统的file结构和VFS的索引节点inode。通过将两个 file 结构指向同一个临时的 VFS 索引节点,而这个 VFS 索引节点又指向一个物理页面而实现的。如图 7.1所示。

图7.1 管道结构示意图

图7.1中有两个 file 数据结构,但它们定义文件操作例程地址是不同的,其中一个是向管道中写入数据的例程地址,而另一个是从管道中读出数据的例程地址。这样,用户程序的系统调用仍然是 通常的文件操作,而内核却利用这种抽象机制实现了管道这一特殊操作。

2.管道的读写

管道实现的源代码在fs/pipe.c中,在pipe.c中有很多函数,其中有两个函数比较重 要,即管道读函数pipe_read()和管道写函数pipe_wrtie()。管道写函数通过将字节复制到 VFS 索引节点指向的物理内存而写入数据,而管道读函数则通过复制物理内存中的字节而读出数据。当然,内核必须利用一定的机制同步对管道的访问,为此,内核使用 了锁、等待队列和信号。

当写进程向管道中写入时,它利用标准的库函数write(),系统根据库函数传递的文件描述 符,可找到该文件的 file 结构。file 结构中指定了用来进行写操作的函数(即写入函数)地址,于是,内核调用该函数完成写操作。写入函数在向内存中写入数据之前,必须首先检查 VFS 索引节点中的信息,同时满足如下条件时,才能进行实际的内存复制工作:
* 内存中有足够的空间可容纳所有要写入的数据;
* 内存没有被读程序锁定。

如果同时满足上述条件,写入函数首先锁定内存,然后从写进程的地址空间中复制数据到内存。否 则,写入进程就休眠在 VFS 索引节点的等待队列中,接下来,内核将调用调度程序,而调度程序会选择其他进程运行。写入进程实际处于可中断的等待状态,当内存中有足够的空间可以容纳写 入数据,或内存被解锁时,读取进程会唤醒写入进程,这时,写入进程将接收到信号。当数据写入内存之后,内存被解锁,而所有休眠在索引节点的读取进程会被唤 醒。

管道的读取过程和写入过程类似。但是,进程可以在没有数据或内存被锁定时立即返回错误信息,而 不是阻塞该进程,这依赖于文件或管道的打开模式。反之,进程可以休眠在索引节点的等待队列中等待写入进程写入数据。当所有的进程完成了管道操作之 后,管道的索引节点被丢弃,而共享数据页也被释放。

因为管道的实现涉及很多文件的操作,因此,当读者学完有关文件系统的内容后来读pipe.c中 的代码,你会觉得并不难理解。

dup()和dup2()函数

dup和dup2也是两个非常有用的调用,它们的作用都是用来复制一个文件的描述符。
它们经常用来重定向进程的stdin、stdout和stderr。

这两个函数的 原形如下:

1
2
3
#include<unistd.h>
int dup( int oldfd );
int dup2( int oldfd, int targetfd )

利用函数dup,我们可以复制一个描述符。传给该函数一个既有的描述符,它就会返回一个新的描述符, 这个新的描述符是传给它的描述符的拷贝。这意味着,这两个描述符共享同一个数据结构。例如, 如果我们对一个文件描述符执行lseek操作,得到的第一个文件的位置和第二个是一样的。 下面是用来说明dup函数使用方法的代码片段:

1
2
3
int fd1, fd2;
...
fd2 = dup( fd1 );

需要注意的是,我们可以在调用fork之前建立一个描述符,这与调用dup建立描述符的效果是一样的, 子进程也同样会收到一个复制出来的描述符。

dup2函数跟dup函数相似,但dup2函数允许调用者规定一个有效描述符和目标描述符的id。dup2函数成功返回时,目标描述符(dup2函数的第二个参数)将变成源描述符(dup2函数的第一个参数)的复制品,换句话说,两个文件描述符现在都指向同一个文件,并且是函数第一个参数指向的文件。下面我们用一段代码加以说明:

1
2
3
4
int oldfd,newfd;
oldfd = open("app_log", (O_RDWR | O_CREATE), 0644);
newfd=dup2( oldfd, 1);//因为目的是重定向标准输出,所以一般不用保存复制出的描述符。
close( oldfd );

本例中,我们打开了一个新文件,称为“app_log”,并收到一个文件描述符,该描述符叫做oldfd。我们调用dup2函数,参数为oldfd和1,这会导致用我们新打开的文件描述符替换掉由1代表的文件描述符(即stdout,因为标准输出文件的id为1)。任何写到stdout的东西,现在都将改为写入名为“app_log”的文件中。

需要注意的是,dup2函数在复制了oldfd之后,会立即将其关闭,但不会关掉新近打开的文件描述符,因为文件描述符1现在也指向它。

代码测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include<stdio.h>
#include<fcntl.h>
int main()
{
	int fd1,fd2,fd3;
	fd1=open("./bcd",O_CREAT|O_RDWR,0644);
	write(fd1,"bcd\n",4);
	fd2=dup(fd1);
	write(fd2,"new\n",4);
	fd3=dup2(fd1,1);
	printf("dup2 test 1\n");
	write(fd3,"ok\n",3);
	printf("dup2 test 2 \n");
	return 1;
}

运行之后,查看bcd中可以看到如下内容:

1
2
3
4
5
bcd
new
ok
dup2 test 1
dup2 test 2

有个疑问:为什么ok在dup2 test 1之后显示呢?

下面的内容还没看懂呢,以后继续:
下面我们介绍一个更加深入的示例代码。回忆一下本文前面讲的命令行管道,在那里,我们将ls –1命令的标准输出作为标准输入连接到wc–l命令。接下来,我们就用一个C程序来加以说明这个过程的实现。代码如下面的示例代码3所示。
在示例代码3中,首先在第9行代码中建立一个管道,然后将应用程序分成两个进程:一个子进程(第13–16行)和一个父进程(第20–23行)。接下来,在子进程中首先关闭stdout描述符(第13行),然后提供了ls–1命令功能,不过它不是写到stdout(第13行),而是写到我们建立的管道的输入端,这是通过dup函数来完成重定向的。在第14行,使用dup2函数把stdout重定向到管道(pfds[1])。之后,马上关掉管道的输入端。然后,使用execlp函数把子进程的映像替换为命令ls–1的进程映像,一旦该命令执行,它的任何输出都将发给管道的输入端。

现在来研究一下管道的接收端。从代码中可以看出,管道的接收端是由父进程来担当的。首先关闭stdin描述符(第20行),因为我们不会从机器的键盘等标准设备文件来接收数据的输入,而是从其它程序的输出中接收数据。然后,再一次用到dup2函数(第21行),让stdin变成管道的输出端,这是通过让文件描述符0(即常规的stdin)等于pfds[0]来实现的。关闭管道的stdout端(pfds[1]),因为在这里用不到它。最后,使用execlp函数把父进程的映像替换为命令wc -1的进程映像,命令wc -1把管道的内容作为它的输入(第23行)。

示例代码3:利用C实现命令的流水线操作的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int main()
{
	int pfds[2];
	if ( pipe(pfds) == 0 ){  //建立一个管道
		if ( fork() == 0 ) {  //子进程
			close(1);    //关闭stdout描述符
			dup2( pfds[1], 1);  //把stdout重定向到管道(pfds[1])
			close( pfds[0]);   //关掉管道的输入端
			execlp( "ls", "ls", "-1", NULL ); //把子进程的映像替换为命令ls–1的进程映像
		} else{    //父进程
			close(0);    //关闭stdin描述符
			dup2( pfds[0], 0);  //让stdin变成管道的输出端
			close( pfds[1]);   //关闭管道的stdout端(pfds[1])
			execlp( "wc", "wc", "-l", NULL ); //把父进程的映像替换为命令wc-1的进程映像
		}
	}
	return 0;
}

在该程序中,需要格外关注的是,我们的子进程把它的输出重定向的管道的输入,然后,父进程将它的输入重定向到管道的输出。

这在实际的应用程序开发中是非常有用的一种技术。

1. 文件描述符在内核中数据结构

在具体说dup/dup2之前, 我认为有必要先了解一下文件描述符在内核中的形态。
一个进程在此存在期间,会有一些文件被打开,从而会返回一些文件描述符,从shell 中运行一个进程,默认会有3个文件描述符存在(0、1、2), 0与进程的标准输入相关联, 1与进程的标准输出相关联,2与进程的标准错误输出相关联,一个进程当前有哪些打开 的文件描述符可以通过/proc/进程ID/fd目录查看。 下图可以清楚的说明问题:

1
2
3
4
5
6
7
8
9
10
11
12
   进程表项 
 ————————————————
    fd标志 文件指针
	  _____________________ 
 fd0:|________|____________|------------>文件表 
 fd1:|________|____________| 
 fd2:|________|____________|
 fd3:|________|____________|
	 |    .........        | 
	 |_____________________|
 
		图1

文件表中包含:文件状态标志、当前文件偏移量、v节点指针,这些不是本文讨论的 重点,我们只需要知道每个打开的文件描述符(fd标志)在进程表中都有自己的文件表 项,由文件指针指向。

2. dup/dup2函数

APUE和man文档都用一句话简明的说出了这两个函数的作用:复制一个现存的文件描述符。

1
2
3
#include<unistd.h>
int dup(int oldfd);
int dup2(int oldfd, int newfd);

从图1来分析这个过程,当调用dup函数时,内核在进程中创建一个新的文件描述符,此 描述符是当前可用文件描述符的最小数值,这个文件描述符指向oldfd所拥有的文件表项。

1
2
3
4
5
6
7
8
9
10
11
12
   进程表项 
 ———————————————— 
    fd标志 文件指针 

	  _____________________ 
 fd0:|________|____________|                  ______ 
 fd1:|________|____________|---------------->|      | 
 fd2:|________|____________|                 |文件表 | 
 fd3:|________|____________|---------------->|______| 
	 |    .........        | 
	 |_____________________|
		图2:调用dup后的示意图

如图2 所示,假如oldfd的值为1, 当前文件描述符的最小值为3, 那么新描述符3指向描述符1所拥有的文件表项。

dup2和dup的区别就是可以用newfd参数指定新描述符的数值,如果newfd已经打开,则先将其关闭。如果newfd等于oldfd,则dup2返回newfd,而不关闭它。dup2函数返回的新文件描述符同样与参数oldfd共享同一文件表项。

APUE用另外一个种方法说明了这个问题: 实际上,调用dup(oldfd); 等效与

1
fcntl(oldfd, F_DUPFD, 0)

而调用dup2(oldfd, newfd); 等效与

1
2
close(oldfd);
fcntl(oldfd, F_DUPFD, newfd);