一个Linux多进程编程?

一个Linux多进程编程?,第1张

1 引言

对于没有接触过Unix/Linux *** 作系统的人来说,fork是最难理解的概念之一:它执行一次却返回两个值。fork函数是Unix系统最杰出的成就之一,它是七十年代UNIX早期的开发者经过长期在理论和实践上的艰苦探索后取得的成果,一方面,它使 *** 作系统在进程管理上付出了最小的代价,另一方面,又为程序员提供了一个简洁明了的多进程方法。与DOS和早期的Windows不同,Unix/Linux系统是真正实现多任务 *** 作的系统,可以说,不使用多进程编程,就不能算是真正的Linux环境下编程。

多线程程序设计的概念早在六十年代就被提出,但直到八十年代中期,Unix系统中才引入多线程机制,如今,由于自身的许多优点,多线程编程已经得到了广泛的应用。

下面,我们将介绍在Linux下编写多进程和多线程程序的一些初步知识。

2 多进程编程

什么是一个进程?进程这个概念是针对系统而不是针对用户的,对用户来说,他面对的概念是程序。当用户敲入命令执行一个程序的时候,对系统而言,它将启动一个进程。但和程序不同的是,在这个进程中,系统可能需要再启动一个或多个进程来完成独立的多个任务。多进程编程的主要内容包括进程控制和进程间通信,在了解这些之前,我们先要简单知道进程的结构。

2.1 Linux下进程的结构

Linux下一个进程在内存里有三部分的数据,就是"代码段"、"堆栈段"和"数据段"。其实学过汇编语言的人一定知道,一般的CPU都有上述三种段寄存器,以方便 *** 作系统的运行。这三个部分也是构成一个完整的执行序列的必要的部分。

"代码段",顾名思义,就是存放了程序代码的数据,假如机器中有数个进程运行相同的一个程序,那么它们就可以使用相同的代码段。"堆栈段"存放的就是子程序的返回地址、子程序的参数以及程序的局部变量。而数据段则存放程序的全局变量,常数以及动态数据分配的数据空间(比如用malloc之类的函数取得的空间)。这其中有许多细节问题,这里限于篇幅就不多介绍了。系统如果同时运行数个相同的程序,它们之间就不能使用同一个堆栈段和数据段。

2.2 Linux下的进程控制

在传统的Unix环境下,有两个基本的 *** 作用于创建和修改进程:函数fork( )用来创建一个新的进程,该进程几乎是当前进程的一个完全拷贝;函数族exec( )用来启动另外的进程以取代当前运行的进程。Linux的进程控制和传统的Unix进程控制基本一致,只在一些细节的地方有些区别,例如在Linux系统中调用vfork和fork完全相同,而在有些版本的Unix系统中,vfork调用有不同的功能。由于这些差别几乎不影响我们大多数的编程,在这里我们不予考虑。

2.2.1 fork( )

fork在英文中是"分叉"的意思。为什么取这个名字呢?因为一个进程在运行中,如果使用了fork,就产生了另一个进程,于是进程就"分叉"了,所以这个名字取得很形象。下面就看看如何具体使用fork,这段程序演示了使用fork的基本框架:

void main(){

int i

if ( fork() == 0 ) {

/* 子进程程序 */

for ( i = 1i <1000i ++ ) printf("This is child process\n")

}

else {

/* 父进程程序*/

for ( i = 1i <1000i ++ ) printf("This is process process\n")

}

}

程序运行后,你就能看到屏幕上交替出现子进程与父进程各打印出的一千条信息了。如果程序还在运行中,你用ps命令就能看到系统中有两个它在运行了。

那么调用这个fork函数时发生了什么呢?fork函数启动一个新的进程,前面我们说过,这个进程几乎是当前进程的一个拷贝:子进程和父进程使用相同的代码段;子进程复制父进程的堆栈段和数据段。这样,父进程的所有数据都可以留给子进程,但是,子进程一旦开始运行,虽然它继承了父进程的一切数据,但实际上数据却已经分开,相互之间不再有影响了,也就是说,它们之间不再共享任何数据了。它们再要交互信息时,只有通过进程间通信来实现,这将是我们下面的内容。既然它们如此相象,系统如何来区分它们呢?这是由函数的返回值来决定的。对于父进程,fork函数返回了子程序的进程号,而对于子程序,fork函数则返回零。在 *** 作系统中,我们用ps函数就可以看到不同的进程号,对父进程而言,它的进程号是由比它更低层的系统调用赋予的,而对于子进程而言,它的进程号即是fork函数对父进程的返回值。在程序设计中,父进程和子进程都要调用函数fork()下面的代码,而我们就是利用fork()函数对父子进程的不同返回值用if...else...语句来实现让父子进程完成不同的功能,正如我们上面举的例子一样。我们看到,上面例子执行时两条信息是交互无规则的打印出来的,这是父子进程独立执行的结果,虽然我们的代码似乎和串行的代码没有什么区别。

读者也许会问,如果一个大程序在运行中,它的数据段和堆栈都很大,一次fork就要复制一次,那么fork的系统开销不是很大吗?其实UNIX自有其解决的办法,大家知道,一般CPU都是以"页"为单位来分配内存空间的,每一个页都是实际物理内存的一个映像,象INTEL的CPU,其一页在通常情况下是4086字节大小,而无论是数据段还是堆栈段都是由许多"页"构成的,fork函数复制这两个段,只是"逻辑"上的,并非"物理"上的,也就是说,实际执行fork时,物理空间上两个进程的数据段和堆栈段都还是共享着的,当有一个进程写了某个数据时,这时两个进程之间的数据才有了区别,系统就将有区别的"页"从物理上也分开。系统在空间上的开销就可以达到最小。

下面演示一个足以"搞死"Linux的小程序,其源代码非常简单:

void main()

{

for( ) fork()

}

这个程序什么也不做,就是死循环地fork,其结果是程序不断产生进程,而这些进程又不断产生新的进程,很快,系统的进程就满了,系统就被这么多不断产生的进程"撑死了"。当然只要系统管理员预先给每个用户设置可运行的最大进程数,这个恶意的程序就完成不了企图了。

2.2.2 exec( )函数族

下面我们来看看一个进程如何来启动另一个程序的执行。在Linux中要使用exec函数族。系统调用execve()对当前进程进行替换,替换者为一个指定的程序,其参数包括文件名(filename)、参数列表(argv)以及环境变量(envp)。exec函数族当然不止一个,但它们大致相同,在Linux中,它们分别是:execl,execlp,execle,execv,execve和execvp,下面我只以execlp为例,其它函数究竟与execlp有何区别,请通过manexec命令来了解它们的具体情况。

一个进程一旦调用exec类函数,它本身就"死亡"了,系统把代码段替换成新的程序的代码,废弃原有的数据段和堆栈段,并为新程序分配新的数据段与堆栈段,唯一留下的,就是进程号,也就是说,对系统而言,还是同一个进程,不过已经是另一个程序了。(不过exec类函数中有的还允许继承环境变量之类的信息。)

那么如果我的程序想启动另一程序的执行但自己仍想继续运行的话,怎么办呢?那就是结合fork与exec的使用。下面一段代码显示如何启动运行其它程序:

char command[256]

void main()

{

int rtn/*子进程的返回数值*/

while(1) {

/* 从终端读取要执行的命令 */

printf( ">" )

fgets( command, 256, stdin )

command[strlen(command)-1] = 0

if ( fork() == 0 ) {

/* 子进程执行此命令 */

execlp( command, command )

/* 如果exec函数返回,表明没有正常执行命令,打印错误信息*/

perror( command )

exit( errorno )

}

else {

/* 父进程, 等待子进程结束,并打印子进程的返回值 */

wait ( &rtn )

printf( " child process return %d\n",. rtn )

}

}

}

此程序从终端读入命令并执行之,执行完成后,父进程继续等待从终端读入命令。熟悉DOS和WINDOWS系统调用的朋友一定知道DOS/WINDOWS也有exec类函数,其使用方法是类似的,但DOS/WINDOWS还有spawn类函数,因为DOS是单任务的系统,它只能将"父进程"驻留在机器内再执行"子进程",这就是spawn类的函数。WIN32已经是多任务的系统了,但还保留了spawn类函数,WIN32中实现spawn函数的方法同前述UNIX中的方法差不多,开设子进程后父进程等待子进程结束后才继续运行。UNIX在其一开始就是多任务的系统,所以从核心角度上讲不需要spawn类函数。

在这一节里,我们还要讲讲system()和popen()函数。system()函数先调用fork(),然后再调用exec()来执行用户的登录shell,通过它来查找可执行文件的命令并分析参数,最后它么使用wait()函数族之一来等待子进程的结束。函数popen()和函数system()相似,不同的是它调用pipe()函数创建一个管道,通过它来完成程序的标准输入和标准输出。这两个函数是为那些不太勤快的程序员设计的,在效率和安全方面都有相当的缺陷,在可能的情况下,应该尽量避免。

2.3 Linux下的进程间通信

详细的讲述进程间通信在这里绝对是不可能的事情,而且笔者很难有信心说自己对这一部分内容的认识达到了什么样的地步,所以在这一节的开头首先向大家推荐著名作者Richard Stevens的著名作品:《Advanced Programming in the UNIX Environment》,它的中文译本《UNIX环境高级编程》已有机械工业出版社出版,原文精彩,译文同样地道,如果你的确对在Linux下编程有浓厚的兴趣,那么赶紧将这本书摆到你的书桌上或计算机旁边来。说这么多实在是难抑心中的景仰之情,言归正传,在这一节里,我们将介绍进程间通信最最初步和最最简单的一些知识和概念。

首先,进程间通信至少可以通过传送打开文件来实现,不同的进程通过一个或多个文件来传递信息,事实上,在很多应用系统里,都使用了这种方法。但一般说来,进程间通信(IPC:InterProcess Communication)不包括这种似乎比较低级的通信方法。Unix系统中实现进程间通信的方法很多,而且不幸的是,极少方法能在所有的Unix系统中进行移植(唯一一种是半双工的管道,这也是最原始的一种通信方式)。而Linux作为一种新兴的 *** 作系统,几乎支持所有的Unix下常用的进程间通信方法:管道、消息队列、共享内存、信号量、套接口等等。下面我们将逐一介绍。

2.3.1 管道

管道是进程间通信中最古老的方式,它包括无名管道和有名管道两种,前者用于父进程和子进程间的通信,后者用于运行于同一台机器上的任意两个进程间的通信。

无名管道由pipe()函数创建:

#include <unistd.h>

int pipe(int filedis[2]);

参数filedis返回两个文件描述符:filedes[0]为读而打开,filedes[1]为写而打开。filedes[1]的输出是filedes[0]的输入。下面的例子示范了如何在父进程和子进程间实现通信。

#define INPUT 0

#define OUTPUT 1

void main() {

int file_descriptors[2]

/*定义子进程号 */

pid_t pid

char buf[256]

int returned_count

/*创建无名管道*/

pipe(file_descriptors)

/*创建子进程*/

if((pid = fork()) == -1) {

printf("Error in fork\n")

exit(1)

}

/*执行子进程*/

if(pid == 0) {

printf("in the spawned (child) process...\n")

/*子进程向父进程写数据,关闭管道的读端*/

close(file_descriptors[INPUT])

write(file_descriptors[OUTPUT], "test data", strlen("test data"))

exit(0)

} else {

/*执行父进程*/

printf("in the spawning (parent) process...\n")

/*父进程从管道读取子进程写的数据,关闭管道的写端*/

close(file_descriptors[OUTPUT])

returned_count = read(file_descriptors[INPUT], buf, sizeof(buf))

printf("%d bytes of data received from spawned process: %s\n",

returned_count, buf)

}

}

在Linux系统下,有名管道可由两种方式创建:命令行方式mknod系统调用和函数mkfifo。下面的两种途径都在当前目录下生成了一个名为myfifo的有名管道:

方式一:mkfifo("myfifo","rw")

方式二:mknod myfifo p

生成了有名管道后,就可以使用一般的文件I/O函数如open、close、read、write等来对它进行 *** 作。下面即是一个简单的例子,假设我们已经创建了一个名为myfifo的有名管道。

/* 进程一:读有名管道*/

#include <stdio.h>

#include <unistd.h>

void main() {

FILE * in_file

int count = 1

char buf[80]

in_file = fopen("mypipe", "r")

if (in_file == NULL) {

printf("Error in fdopen.\n")

exit(1)

}

while ((count = fread(buf, 1, 80, in_file)) >0)

printf("received from pipe: %s\n", buf)

fclose(in_file)

}

/* 进程二:写有名管道*/

#include <stdio.h>

#include <unistd.h>

void main() {

FILE * out_file

int count = 1

char buf[80]

out_file = fopen("mypipe", "w")

if (out_file == NULL) {

printf("Error opening pipe.")

exit(1)

}

sprintf(buf,"this is test data for the named pipe example\n")

fwrite(buf, 1, 80, out_file)

fclose(out_file)

}

2.3.2 消息队列

消息队列用于运行于同一台机器上的进程间通信,它和管道很相似,事实上,它是一种正逐渐被淘汰的通信方式,我们可以用流管道或者套接口的方式来取代它,所以,我们对此方式也不再解释,也建议读者忽略这种方式。

2.3.3 共享内存

共享内存是运行在同一台机器上的进程间通信最快的方式,因为数据不需要在不同的进程间复制。通常由一个进程创建一块共享内存区,其余进程对这块内存区进行读写。得到共享内存有两种方式:映射/dev/mem设备和内存映像文件。前一种方式不给系统带来额外的开销,但在现实中并不常用,因为它控制存取的将是实际的物理内存,在Linux系统下,这只有通过限制Linux系统存取的内存才可以做到,这当然不太实际。常用的方式是通过shmXXX函数族来实现利用共享内存进行存储的。

首先要用的函数是shmget,它获得一个共享存储标识符。

#include <sys/types.h>

#include <sys/ipc.h>

#include <sys/shm.h>

int shmget(key_t key, int size, int flag)

这个函数有点类似大家熟悉的malloc函数,系统按照请求分配size大小的内存用作共享内存。Linux系统内核中每个IPC结构都有的一个非负整数的标识符,这样对一个消息队列发送消息时只要引用标识符就可以了。这个标识符是内核由IPC结构的关键字得到的,这个关键字,就是上面第一个函数的key。数据类型key_t是在头文件sys/types.h中定义的,它是一个长整形的数据。在我们后面的章节中,还会碰到这个关键字。

当共享内存创建后,其余进程可以调用shmat()将其连接到自身的地址空间中。

void *shmat(int shmid, void *addr, int flag)

shmid为shmget函数返回的共享存储标识符,addr和flag参数决定了以什么方式来确定连接的地址,函数的返回值即是该进程数据段所连接的实际地址,进程可以对此进程进行读写 *** 作。

使用共享存储来实现进程间通信的注意点是对数据存取的同步,必须确保当一个进程去读取数据时,它所想要的数据已经写好了。通常,信号量被要来实现对共享存储数据存取的同步,另外,可以通过使用shmctl函数设置共享存储内存的某些标志位如SHM_LOCK、SHM_UNLOCK等来实现。

管道用于有学园关系的进程之间。

管道的pipe 系统调用实际上就是创建出来两个文件描述符。

当父进P1程创建出 fd[2] 时,子进程P2 会继承父进程的所有,所以也会得到pipe 的 2个 文件描述符。

所以毫无瓜葛的两个进程,一定不会访问到彼此的pipe。无法用管道进行通信。

管道一般是单工的。f[0]读,f[1]写

管道也可以适用于 兄弟进程(只要有血缘即可)。由于管道是单工的,当两个进程之间需要双向通信,则需要两跟管道。

执行

ctrl-c(2号信号) + SIGUSR1 信号 绑了一个新函数。则 ctrl-c 无效。

查看进程的信号

号信号被捕获。

将2号信号忽略掉

9号信号 kill 和19号信号 stop 不能乱搞,只能用缺省。

其它信号甚至段信号也都可以捕获。

改变程序的执行现场,修改PC指针,有些像goto,只不过返回非0值

运行结果

making segment fault

after segment fault

程序不会死。

如果不忽略 page fault

则会产生 core dump.

不停的给data 赋值,同时每隔1s会有信号进来,打印 data的值。

理论上打印出来的结果同时为0,同时为1

但并非如此,是 0,1,交替随机的。

signal 异步的,随时都可以进来,所以打印出来的结果,并不是我想要的。

信号对于应用程序来说,很像中断对于内核,都是访问临界区数据

信号被屏蔽,延后执行。

写多线程的程序时,不要以为只有线程之间有竞争,其实信号也会有竞争

system v 的IPC 年代有些久远。

有血缘关系的进程 key_t 都是相同的。

Key 是私有key IPV PRIVATE

可能用消息队列,可能用共享内存,可能用信号量进行通讯。

利用 _pathname 路径,约定好一条路径。和tcp/ip地址很像,来生成一个key_t key, 用msg_get shm_get 得到共享内存or 信号量。

int id 可以理解为文件描述符 fd。

其中Sys V 的共享内存 最为常用。

一定要检查路径,如果仅仅有2个进程,你没有创建路径,两者都是 -1(相当于大家约定好了),那当然能通信拉。但更多的进程出现,则会有问题。

一定要检查返回值

依然依靠key,但是api 实在是太挫了。P&V *** 作都是 semop. (posix 的 ipc跟为简洁)

POSIX 共享内存当然也需要一个名字,但并不是路径。

无论读进程还是写进程,都需要传入相同的名字。

如果是unbuntu 会在以下路径生成文件

其实 2和3 是1 的符号链接。 只要保证是一个就能互相通信

关键点,mmap 内存的属性修改为 private 后,产生写时copy,虚拟地址一样,但是物理地址已经不同了

当然 如果子进程修改了程序背景,执行了 exec,那么完全不一样了,直接修改了内存逻辑。

这个问题需要的知识主要包括:

1 多进程间进行通信;

2 使用同步信号量(semaphore)和互斥信号量(mutex)进行数据保护。

参考代码如下,可以参照注释辅助理解:

#include <stdio.h>

#include <stdlib.h>

#include <unistd.h>

#include <pthread.h>

#include <semaphore.h>

#define N 2   // 消费者或者生产者的数目

#define M 10 // 缓冲数目

int in = 0   // 生产者放置产品的位置

int out = 0 // 消费者取产品的位置

int buff[M] = {0} // 缓冲初始化为0, 开始时没有产品

sem_t empty_sem // 同步信号量, 当满了时阻止生产者放产品

sem_t full_sem   // 同步信号量, 当没产品时阻止消费者消费

pthread_mutex_t mutex // 互斥信号量, 一次只有一个线程访问缓冲

int product_id = 0   //生产者id

int prochase_id = 0 //消费者id

/* 打印缓冲情况 */

void print()

{

int i

for(i = 0 i < M i++)

   printf("%d ", buff[i])

printf("\n")

}

/* 生产者方法 */ 

void *product()

{

int id = ++product_id

while(1)

{

   // 用sleep的数量可以调节生产和消费的速度,便于观察

   sleep(1)

   //sleep(1)

  

   sem_wait(&empty_sem)

   pthread_mutex_lock(&mutex)

  

   in = in % M

   printf("product%d in %d. like: \t", id, in)

  

   buff[in] = 1  

   print()  

   ++in

  

   pthread_mutex_unlock(&mutex)

   sem_post(&full_sem)  

}

}

/* 消费者方法 */

void *prochase()

{

int id = ++prochase_id

while(1)

{

   // 用sleep的数量可以调节生产和消费的速度,便于观察

   sleep(1)

//sleep(1)

  

   sem_wait(&full_sem)

   pthread_mutex_lock(&mutex)

  

   out = out % M

   printf("prochase%d in %d. like: \t", id, out)

  

   buff[out] = 0

   print()

   ++out

  

   pthread_mutex_unlock(&mutex)

   sem_post(&empty_sem)

}

}

int main()

{

pthread_t id1[N]

pthread_t id2[N]

int i

int ret[N]

// 初始化同步信号量

int ini1 = sem_init(&empty_sem, 0, M) 

int ini2 = sem_init(&full_sem, 0, 0)  

if(ini1 && ini2 != 0)

{

   printf("sem init failed \n")

   exit(1)

//初始化互斥信号量 

int ini3 = pthread_mutex_init(&mutex, NULL)

if(ini3 != 0)

{

   printf("mutex init failed \n")

   exit(1)

// 创建N个生产者线程

for(i = 0 i < N i++)

{

   ret[i] = pthread_create(&id1[i], NULL, product, (void *)(&i))

   if(ret[i] != 0)

   {

    printf("product%d creation failed \n", i)

    exit(1)

   }

}

//创建N个消费者线程

for(i = 0 i < N i++)

{

   ret[i] = pthread_create(&id2[i], NULL, prochase, NULL)

   if(ret[i] != 0)

   {

    printf("prochase%d creation failed \n", i)

    exit(1)

   }

}

//销毁线程

for(i = 0 i < N i++)

{

   pthread_join(id1[i],NULL)

   pthread_join(id2[i],NULL)

}

exit(0) 

}

在Linux下编译的时候,要在编译命令中加入选项-lpthread以包含多线程支持。比如存储的C文件为demo.c,要生成的可执行文件为demo。可以使用命令:

gcc demo.c -o demo -lpthread

程序中为便于观察,使用了sleep(1)来暂停运行,所以查看输出的时候可以看到,输出是每秒打印一次的。


欢迎分享,转载请注明来源:内存溢出

原文地址: https://www.outofmemory.cn/yw/8010216.html

(0)
打赏 微信扫一扫 微信扫一扫 支付宝扫一扫 支付宝扫一扫
上一篇 2023-04-12
下一篇 2023-04-12

发表评论

登录后才能评论

评论列表(0条)

保存