一个大型的应用系统,往往需要众多进程协作,进程(Linux进程概念见附1)间通信的重要性显而易见。本系列文章阐述了 Linux环境下的几种主要进程间通信手段,并针对每个通信手段关键技术环节给出详细实例。为达到阐明问题的目的,本文还对某些通信手段的内部实现机制进 行了分析。
linux 下的进程通信手段基本上是从Unix平台上的进程通信手段继承而来的。而对Unix发展做出重大贡献的两大主力AT&T的贝尔实验室及BSD(加 州大学伯克利分校的伯克利软件发布中心)在进程间通信方面的侧重点有所不同。前者对Unix早期的进程间通信手段进行了系统的改进和扩充,形成了 “system V IPC”,通信进程局限在单个计算机内;后者则跳过了该限制,形成了基于套接口(socket)的进程间通信机制。Linux则把两者继承了下来,如图 示:
其 中,最初Unix IPC包括:管道、FIFO、信号;System V IPC包括:System V消息队列、System V信号灯、System V共享内存区;Posix IPC包括: Posix消息队列、Posix信号灯、Posix共享内存区。有两点需要简单说明一下:1)由于Unix版本的多样性,电子电气工程协会(IEEE)开 发了一个独立的Unix标准,这个新的ANSI Unix标准被称为计算机环境的可移植性操作系统界面(PSOIX)。现有大部分Unix和流行版本都是遵循POSIX标准的,而Linux从一开始就遵 循POSIX标准;2)BSD并不是没有涉足单机内的进程间通信(socket本身就可以用于单机内的进程间通信)。事实上,很多Unix版本的单机 IPC留有BSD的痕迹,如4.4BSD支持的匿名内存映射、4.3+BSD对可靠信号语义的实现等等。
图 一给出了linux 所支持的各种IPC手段,在本文接下来的讨论中,为了避免概念上的混淆,在尽可能少提及Unix的各个版本的情况下,所有问题的讨论最终都会归结到Linux环境下的进程间通信上来。并且,对于Linux所支持通信手段的不同实现版本(如对于共享内存来说,有Posix共享内存区以及System V共享内存区两个实现版本),将主要介绍Posix API。
linux下进程间通信的几种主要手段简介:
1. 管道(Pipe)及有名管道(named pipe):
管道可用于具有亲缘关系进程间的通信,有名管道克服了管道没有名字的限制,因此,除具有管道所具有的功能外,它还允许无亲缘关系进程间的通信;
2. 信 号(Signal):
信号是比较复杂的通信方式,用于通知接受进程有某种事件发生,除了用于进程间通信外,进程还可以发送信号给进程本身;linux除了 支持Unix早期信号语义函数sigal外,还支持语义符合Posix.1标准的信号函数sigaction(实际上,该函数是基于BSD的,BSD为了 实现可靠信号机制,又能够统一对外接口,用sigaction函数重新实现了signal函数);
3. 报文(Message)队列(消息队列):
消息队列是消息的链接表,包括Posix消息队列system V消息队列。有足够权限的进程可以向队列中添加消息,被赋予读权限的进程则可以读走队列中的消息。消息队列克服了信号承载信息量少,管道只能承载无格式字节流以及缓冲区大小受限等缺点。
4. 共享内存:
使得多个进程可以访问同一块内存空间,是最快的可用IPC形式。是针对其他通信机制运行效率较低而设计的。往往与其它通信机制,如信号量结合使用,来达到进程间的同步及互斥。
5. 信号量(semaphore):
主要作为进程间以及同一进程不同线程之间的同步手段。
6. 套接口(Socket):
更为一般的进程间通信机制,可用于不同机器之间的进程间通信。起初是由Unix系统的BSD分支开发出来的,但现在一般可以移植到其它类Unix系统上:Linux和System V的变种都支持套接字。
下面将对上述通信机制做具体阐述。
一般来说,linux下的进程包含以下几个关键要素:
· 有一段可执行程序;
· 有专用的系统堆栈空间;
· 内核中有它的控制块(进程控制块),描述进程所占用的资源,这样,进程才能接受内核的调度;
· 具有独立的存储空间
进程和线程有时候并不完全区分,而往往根据上下文理解其含义。
· UNIX环境高级编程,作者:W.Richard Stevens,译者:尤晋元等,机械工业出版社。具有丰富的编程实例,以及关键函数伴随Unix的发展历程。
· linux内核源代码情景分析(上、下),毛德操、胡希明著,浙江大学出版社,提供了对linux内核非常好的分析,同时,对一些关键概念的背景进行了详细的说明;对linux环境下的进程进行了概括说明。
· UNIX网络编程第二卷:进程间通信,作者:W.Richard Stevens,译者:杨继张,清华大学出版社。一本比较全面阐述Unix环境下进程间通信的书(没有信号和套接口,套接口在第一卷中)。
进程间通信与应用程序间通信及其实现技术
---- 摘 要 本文讨论了进程间通信与应用程序间通信的含义及相应的实现技术,并对这些技术的原理、特性等进行了深入的分析和比较。
---- 关键词 信号 管道 消息队列 共享存储段 信号灯 远程过程调用 Socket套接字MQSeries
1 引言
---- 进程间通信的主要目的是实现同一计算机系统内部的相互协作的进程之间的数据共享与信息交换,由于这些进程处于同一软件和硬件环境下,利用操作系统提供的的 编程接口,用户可以方便地在程序中实现这种通信;应用程序间通信的主要目的是实现不同计算机系统中的相互协作的应用程序之间的数据共享与信息交换,由于应 用程序分别运行在不同计算机系统中,它们之间要通过网络之间的协议才能实现数据共享与信息交换。进程间通信和应用程序间通信及相应的实现技术有许多相同之 处,也各有自己的特色。即使是同一类型的通信也有多种的实现方法,以适应不同情况的需要。
---- 为了充分认识和掌握这两种通信及相应的实现技术,本文将就以下几个方面对这两种通信进行深入的讨论:问题的由来、解决问题的策略和方法、每种方法的工作原理和实现、每种实现方法的特点和适用的范围等。
2 进程间的通信及其实现技术
---- 用户提交给计算机的任务最终都是通过一个个的进程来完成的。在一组并发进程中的任何两个进程之间,如果都不存在公共变量,则称该组进程为不相交的。在不相 交的进程组中,每个进程都独立于其它进程,它的运行环境与顺序程序一样,而且它的运行环境也不为别的进程所改变。运行的结果是确定的,不会发生与时间相关 的错误。
---- 但是,在实际中,并发进程的各个进程之间并不是完全互相独立的,它们之间往往存在着相互制约的关系。进程之间的相互制约关系表现为两种方式:
---- (1) 间接相互制约:共享CPU
---- (2) 直接相互制约:竞争和协作
---- 竞争——进程对共享资源的竞争。为保证进程互斥地访问共享资源,各进程必须互斥地进入各自的临界段。
---- 协作——进程之间交换数据。为完成一个共同任务而同时运行的一组进程称为同组进程,它们之间必须交换数据,以达到协作完成任务的目的,交换数据可以通知对方可以做某事或者委托对方做某事。
---- 共享CPU问题由操作系统的进程调度来实现,进程间的竞争和协作由进程间的通信来完成。进程间的通信一般由操作系统提供编程接口,由程序员在程序中实现。UNIX在这个方面可以说最具特色,它提供了一整套进程间的数据共享与信息交换的处理方法——进程通信机制(IPC)。因此,我们就以UNIX为例来分析 进程间通信的各种实现技术。
---- 在UNIX中,文件(File)、信号(Signal)、无名管道(Unnamed Pipes)、有名管道(FIFOs)是传统IPC功能;新的IPC功能包括消息队列(Message queues)、共享存储段(Shared memory segment)和信号灯(Semapores)。
---- (1) 信号
---- 信号机制是UNIX为进程中断处理而设置的。它只是一组预定义的值,因此不能用于信息交换,仅用于进程中断控制。例如在发生浮点错、非法内存访问、执行无 效指令、某些按键(如ctrl-c、del等)等都会产生一个信号,操作系统就会调用有关的系统调用或用户定义的处理过程来处理。
---- 信号处理的系统调用是signal,调用形式是:
---- signal(signalno,action)
---- 其中,signalno是规定信号编号的值,action指明当特定的信号发生时所执行的动作。
---- (2) 无名管道和有名管道
---- 无名管道实际上是内存中的一个临时存储区,它由系统安全控制,并且独立于创建它的进程的内存区。管道对数据采用先进先出方式管理,并严格按顺序操作,例如不能对管道进行搜索,管道中的信息只能读一次。
---- 无名管道只能用于两个相互协作的进程之间的通信,并且访问无名管道的进程必须有共同的祖先。
---- 系统提供了许多标准管道库函数,如:
pipe()——打开一个可以读写的管道;
close()——关闭相应的管道;
read()——从管道中读取字符;
write()——向管道中写入字符;
---- 有名管道的操作和无名管道类似,不同的地方在于使用有名管道的进程不需要具有共同的祖先,其它进程,只要知道该管道的名字,就可以访问它。管道非常适合进程之间快速交换信息。
---- (3) 消息队列(MQ)
---- 消息队列是内存中独立于生成它的进程的一段存储区,一旦创建消息队列,任何进程,只要具有正确的的访问权限,都可以访问消息队列,消息队列非常适合于在进程间交换短信息。
---- 消息队列的每条消息由类型编号来分类,这样接收进程可以选择读取特定的消息类型——这一点与管道不同。消息队列在创建后将一直存在,直到使用msgctl系统调用或iqcrm -q命令删除它为止。
---- 系统提供了许多有关创建、使用和管理消息队列的系统调用,如:
---- int msgget(key,flag)——创建一个具有flag权限的MQ及其相应的结构,并返回一个唯一的正整数msqid(MQ的标识符);
---- int msgsnd(msqid,msgp,msgsz,msgtyp,flag)——向队列中发送信息;
---- int msgrcv(msqid,cmd,buf)——从队列中接收信息;
---- int msgctl(msqid,cmd,buf)——对MQ的控制操作;
---- (4) 共享存储段(SM)
---- 共享存储段是主存的一部分,它由一个或多个独立的进程共享。各进程的数据段与共享存储段相关联,对每个进程来说,共享存储段有不同的虚拟地址。系统提供的有关SM的系统调用有:
---- int shmget(key,size,flag)——创建大小为size的SM段,其相应的数据结构名为key,并返回共享内存区的标识符shmid;
---- char shmat(shmid,address,flag)——将当前进程数据段的地址赋给shmget所返回的名为shmid的SM段;
---- int shmdr(address)——从进程地址空间删除SM段;
---- int shmctl (shmid,cmd,buf)——对SM的控制操作;
---- SM的大小只受主存限制,SM段的访问及进程间的信息交换可以通过同步读写来完成。同步通常由信号灯来实现。SM非常适合进程之间大量数据的共享。
---- (5) 信号灯
---- 在UNIX中,信号灯是一组进程共享的数据结构,当几个进程竞争同一资源时(文件、共享内存或消息队列等),它们的操作便由信号灯来同步,以防止互相干扰。
---- 信号灯保证了某一时刻只有一个进程访问某一临界资源,所有请求该资源的其它进程都将被挂起,一旦该资源得到释放,系统才允许其它进程访问该资源。信号灯通常配对使用,以便实现资源的加锁和解锁。
---- 进程间通信的实现技术的特点是:操作系统提供实现机制和编程接口,由用户在程序中实现,保证进程间可以进行快速的信息交换和大量数据的共享。但是,上述方式主要适合在同一台计算机系统内部的进程之间的通信。
3 应用程序间的通信及其实现技术
---- 同进程之间的相互制约一样,不同的应用程序之间也存在竞争和协作的关系。UNIX操作系统也提供一些可用于应用程序之间实现数据共享与信息交换的编程接 口,程序员可以通过自己编程来实现。如远程过程调用和基于TCP/IP协议的套接字(Socket)编程。但是,相对普通程序员来说,它们涉及的技术比较 深,编程也比较复杂,实现起来困难较大。
---- 于是,一种新的技术应运而生——通过将有关通信的细节完全掩盖在某个独立软件内部,即底层的通讯工作和相应的维护管理工作由该软件内部来实现,用户只需要将通信任务提交给该软件去完成,而不必理会它的具体工作过程——这就是所谓的中间件技术。
---- 我们在这里分别讨论这三种常用的应用程序间通信的实现技术——远程过程调用、会话编程技术和MQSeries消息队列技术。其中远程过程调用和会话编程属 于比较低级的方式,程序员参与的程度较深,而MQSeries消息队列则属于比较高级的方式,即中间件方式,程序员参与的程度较浅。
---- 3.1 远程过程调用(RPC)
---- 远程过程调用是按下述方式工作的:当一个应用程序A需要与远程的另一个应用程序B交换信息或要求B提供协助时,A将在本地产生一个请求,通过通讯链路,通知B接收信息或提供相应的服务,B完成相关处理后将确认信息或结果返回给A。
---- RPC机制强调通信的两个应用程序所处的环境和平台中必须是相同的,而且必须同时处于运行状态。做远程调用时,两者必须先建立连接,而且通讯链路质量对它的效果影响很大。
---- RPC的优点是应用程序采用调用/返回方式通讯,拥有很高的潜在效率,但需要应用程序间的紧密藕合,通讯线路必须在通信期间一直保持良好的状态,而且必须进行大量的底层通讯的编程工作。
---- 3.2 会话编程
---- 会话编程类似于人们打电话,拨号——接通——说话——对方回答——挂机。基于TCP/IP协议的Socket编程就是一种典型的会话编程方式。它可适用于客户/服务通信方式,还能适用于点——点通信方式。
---- 下面,我们分别介绍服务器端和客户端的具体任务。
---- 服务器端
---- 服务进程首先创建一个套接口,使用Socket()调用;然后,将该套接口与本机的IP地址和某一空闲端口相关联,使用Bind()调用;这时,服务端就 可以用Listen()调用来侦听来自客户程序的数据;套接口一旦处于听模式,服务进程将可以接收一个连接,并允许传递数据,使用Accept()调用来 完成;最后使用Read()调用来读入数据,同时,还可以用Write()调用来向发送进程写回一些数据,如确认信息或回显信息。
客户端
---- 客户进程也是首先创建一个套接口,使用Socket()调用;然后,客户进程就使用Connect()调用试图连接一个服务;连接成功之后,就可以利用Write()调用向服务器发送数据,同时,还可以使用Read()调用读取服务器写回的数据。
---- 目前的网络一般都支持TCP/IP协议,UNIX和WINDOWS也都提供相应的编程接口,用户可以随心所欲地编制出合乎自己要求的通信程序。现行大多数 的应用程序间的通信采取的就是这种方式。但是,这种Socket编程技术,要求程序员必须熟悉相关概念,自己设计控制流程,客户和服务进程必须相互配合且 必须都处于运行状态,技术上有一定的难度。
---- 3.3 MQSeries消息队列
---- 为了简化应用程序间的通信,使得通信既具有较高的可靠性,又保证实现的简单性,我们希望能有一种独立的通信软件,应用程序只需将任务提交给该软件,由该软 件自动去完成信息的传递工作,这即是我们前面提到的中间件技术。IBM公司的MQSeries就是基于这种技术的商业化产品。
---- 应用程序A和B位于同一计算机,而应用程序C位于远程的其它计算机系统中。当应用程序A需要和B通讯时,它通过调用MQSeries接口将消息放入队列 Q1,应用程序B在适当的时候读取该消息,或消息本身到达后唤醒应用程序B。当应用程序A需要和C通讯时,它通过相同的方式将消息放入队列Q2,应用程序 C在适当的时候读取该消息。
---- 应用程序之间的消息传递是通过队列来实现的,是间接的。由于不存在直接连接,C关闭时A仍然能正常运行,不仅如此,当C不在运行时,消息还可以触发该程序。
---- MQSeries优点可以确保信息是永久的、可恢复的;确保信息成功发送且仅有一次发送,可以支持关键业务,如证券交易信息的传递;确保信息传递是保密 的;同时,使用MQSeries,不需要应用程序和通讯介质以及远程应用程序之间的耦合,也不需要应用程序同时运行。MQSeries是应用程序间通信的 首选技术。
---- MQSeries接口提供的调用主要有:
---- MQCONN——连接一个队列管理器,以后它发送和读入的消息的所有消息都由这个队列管理器管理;
---- MQOPEN——打开该应用程序所连接的队列;
---- MQPUT——将消息写入已打开的队列中;
---- MQGET——从该队列中读出消息;
---- MQINQ——获得关于队列的属性;
---- MQCLOSE——关闭队列(对队列执行完所有操作后);
---- MQPUT1——它执行三个操作,先调用MQOPEN打开队列,然后调用MQPUT写入一条消息,最后调用MQCLOSE关闭队列;
---- MQDISC——断开和队列管理器的连接(对队列管理器的所有操作完成后);
---- 3.4 三种实现技术的特性比较
表1清楚地列出了RPC、 Socket编程、MQSeries的不同特性。
比较项目 | Socket编程 | RPC | MQSeries |
属性 | 会话 | 远程调用 | 消息队列 |
类型 | 会话 | 调用/返回 | 队列 |
编程接口 | 非阻塞 | 阻塞 | 非阻塞 |
通信对方运行 | 是 | 是 | 否 |
应用程序类型 | 面向连接 | 面向连接 | 无连接 |
数据流模式 | 点-点,客户机/服务器 | 客户机/服务器 | 所有模式 |
逻辑路由 | 否 | 否 | 是 |
永久数据 | 否 | 否 | 是 |
表1 三种中间件的特性比较
4 结束语
---- 各种进程间通信和应用程序间通信的实现技术都具有自己的特点和使用范围。管道、消息队列、共享内存等技术最适用于同一计算机系统内部的进程间通信,以保证 高效率。而远程过程调用、Socket会话编程、MQSeries则最适用于远程的应用程序之间通信,可以简化通信的编程,当然也保证通信的可靠性。尤其 是MQSeries,它是一个比较完善的中间件产品,为许多的信息系统所选用。如我公司的帐务系统与各金融系统的话费信息的交换选择的就是 MQSeries。有时,在一个信息系统里面,既存在进程间通信的需求,也存在应用程序间通信的需求,这时就必须分别选择两种不同的实现技术。因此,在实 际信息系统建设的过程中,我们在选择哪种实现技术时,应根据信息系统的不同情况和不同需求,根据系统开发和维护的成本,选择一种或是几种实现技术,以求得 整个系统的优化。
-
Linux环境进程间通信: 共享内存
采用共享内存通信的一个显而易见的好处是效率高,因为进程可以直接读写内存,而不需要任何数据的拷贝。对于像管道和消息队列等通信方式,则需要在内核和用户空间进行四次的数据拷贝,而共享内存则只拷贝两次数据[1]:一次从输入文件到共享内存区,另一次从共享内存区到输出文件。实际上,进程之间在共享内存时,并不总是读写少量数据后就解除映射,有新的通信时,再重新建立共享内存区域。而是保持共享区域,直到通信完毕为止,这样,数据内容一直保存在共享内存中,并没有写回文件。共享内存中的内容往往是在解除映射时才写回文件的。因此,采用共享内存的通信方式效率是非常高的。
Linux的2.2.x内核支持多种共享内存方式,如mmap()系统调用,Posix共享内存,以及系统V共享内存。linux发行版本如Redhat 8.0支持mmap()系统调用及系统V共享内存,但还没实现Posix共享内存,本文将主要介绍mmap()系统调用及系统V共享内存API的原理及应用。
一、内核怎样保证各个进程寻址到同一个共享内存区域的内存页面
1、page cache及swap cache中页面的区分:
一个被访问文件的物理页面都驻留在page cache或swap cache中,一个页面的所有信息由struct page来描述。struct page中有一个域为指针mapping ,它指向一个struct address_space类型结构。page cache或swap cache中的所有页面就是根据address_space结构以及一个偏移量来区分的。
2、文件与address_space结构的对应:
一个具体的文件在打开后,内核会在内存中为之建立一个struct inode结构,其中的i_mapping域指向一个address_space结构。这样,一个文件就对应一个address_space结构,一个address_space与一个偏移量能够确定一个page cache 或swap cache中的一个页面。因此,当要寻址某个数据时,很容易根据给定的文件及数据在文件内的偏移量而找到相应的页面。
3、进程调用mmap():
此时,只是在进程空间内新增了一块相应大小的缓冲区,并设置了相应的访问标识,但并没有建立进程空间到物理页面的映射。因此,第一次访问该空间时,会引发一个缺页异常。
4、对于共享内存映射情况:
缺页异常处理程序首先在swap cache中寻找目标页(符合address_space以及偏移量的物理页),如果找到,则直接返回地址;如果没有找到,则判断该页是否在交换区(swap area),如果在,则执行一个换入操作;如果上述两种情况都不满足,处理程序将分配新的物理页面,并把它插入到page cache中。进程最终将更新进程页表。 注:对于映射普通文件情况(非共享映射),缺页异常处理程序首先会在page cache中根据address_space以及数据偏移量寻找相应的页面。如果没有找到,则说明文件数据还没有读入内存,处理程序会从磁盘读入相应的页面,并返回相应地址,同时,进程页表也会更新。
5、所有进程在映射同一个共享内存区域时:
情况都一样,在建立线性地址与物理地址之间的映射之后,不论进程各自的返回地址如何,实际访问的必然是同一个共享内存区域对应的物理页面。 注:一个共享内存区域可以看作是特殊文件系统shm中的一个文件,shm的安装点在交换区上。
上面涉及到了一些数据结构,围绕数据结构理解问题会容易一些。
二、mmap()及其相关系统调用
mmap()系统调用使得进程之间通过映射同一个普通文件实现共享内存。普通文件被映射到进程地址空间后,进程可以向访问普通内存一样对文件进行访问,不必再调用read(),write()等操作。
注:实际上,mmap()系统调用并不是完全为了用于共享内存而设计的。它本身提供了不同于一般对普通文件的访问方式,进程可以像读写内存一样对普通文件的操作。而Posix或系统V的共享内存IPC则纯粹用于共享目的,当然mmap()实现共享内存也是其主要应用之一。
1、mmap()系统调用形式如下:
void* mmap ( void * addr , size_t len , int prot , int flags , int fd , off_t offset )
参数fd为即将映射到进程空间的文件描述字,一般由open()返回,同时,fd可以指定为-1,此时须指定flags参数中的MAP_ANON,表明进行的是匿名映射(不涉及具体的文件名,避免了文件的创建及打开,很显然只能用于具有亲缘关系的进程间通信)。len是映射到调用进程地址空间的字节数,它从被映射文件开头offset个字节开始算起。prot 参数指定共享内存的访问权限。可取如下几个值的或:PROT_READ(可读) , PROT_WRITE (可写), PROT_EXEC (可执行), PROT_NONE(不可访问)。flags由以下几个常值指定:MAP_SHARED , MAP_PRIVATE , MAP_FIXED,其中,MAP_SHARED , MAP_PRIVATE必选其一,而MAP_FIXED则不推荐使用。offset参数一般设为0,表示从文件头开始映射。参数addr指定文件应被映射到进程空间的起始地址,一般被指定一个空指针,此时选择起始地址的任务留给内核来完成。函数的返回值为最后文件映射到进程空间的地址,进程可直接操作起始地址为该值的有效地址。这里不再详细介绍mmap()的参数,读者可参考mmap()手册页获得进一步的信息。
2、系统调用mmap()用于共享内存的两种方式:
(1)使用普通文件提供的内存映射:适用于任何进程之间;此时,需要打开或创建一个文件,然后再调用mmap();典型调用代码如下:
ptr=mmap(NULL, len , PROT_READ|PROT_WRITE, MAP_SHARED , fd , 0); 通过mmap()实现共享内存的通信方式有许多特点和要注意的地方,我们将在范例中进行具体说明。
2 使用特殊文件提供匿名内存映射:适用于具有亲缘关系的进程之间;由于父子进程特殊的亲缘关系,在父进程中先调用mmap(),然后调用fork()。那么在调用fork()之后,子进程继承父进程匿名映射后的地址空间,同样也继承mmap()返回的地址,这样,父子进程就可以通过映射区域进行通信了。注意,这里不是一般的继承关系。一般来说,子进程单独维护从父进程继承下来的一些变量。而mmap()返回的地址,却由父子进程共同维护。 对于具有亲缘关系的进程实现共享内存最好的方式应该是采用匿名内存映射的方式。此时,不必指定具体的文件,只要设置相应的标志即可,参见范例2。
fd=open(name, flag, mode);
if(fd<0)
...
3、系统调用munmap()
int munmap( void * addr, size_t len ) 该调用在进程地址空间中解除一个映射关系,addr是调用mmap()时返回的地址,len是映射区的大小。当映射关系解除后,对原来映射地址的访问将导致段错误发生。
4、系统调用msync()
int msync ( void * addr , size_t len, int flags) 一般说来,进程在映射空间的对共享内容的改变并不直接写回到磁盘文件中,往往在调用munmap()后才执行该操作。可以通过调用msync()实现磁盘上文件内容与共享内存区的内容一致。
三、mmap()范例
下面将给出使用mmap()的两个范例:范例1给出两个进程通过映射普通文件实现共享内存通信;范例2给出父子进程通过匿名映射实现共享内存。系统调用mmap()有许多有趣的地方,下面是通过mmap()映射普通文件实现进程间的通信的范例,我们通过该范例来说明mmap()实现共享内存的特点及注意事项。
范例1:两个进程通过映射普通文件实现共享内存通信
范例1包含两个子程序:map_normalfile1.c及map_normalfile2.c。编译两个程序,可执行文件分别为map_normalfile1及map_normalfile2。两个程序通过命令行参数指定同一个文件来实现共享内存方式的进程间通信。map_normalfile2试图打开命令行参数指定的一个普通文件,把该文件映射到进程的地址空间,并对映射后的地址空间进行写操作。map_normalfile1把命令行参数指定的文件映射到进程地址空间,然后对映射后的地址空间执行读操作。这样,两个进程通过命令行参数指定同一个文件来实现共享内存方式的进程间通信。
下面是两个程序代码:
/*-------------map_normalfile2.c-----------*/
#include <sys/mman.h>
#include <sys/types.h>
#include <fcntl.h>
#include <unistd.h>
typedef struct{
char name[4];
int age;
}people;
main(int argc, char** argv) // map a normal file as shared mem:
{
int fd,i;
people *p_map;
char temp;
fd=open(argv[1],O_CREAT|O_RDWR|O_TRUNC,00777);
lseek(fd,sizeof(people)*5-1,SEEK_SET);
write(fd,"",1);
p_map = (people*) mmap( NULL,sizeof(people)*10,PROT_READ|PROT_WRITE,MAP_SHARED,fd,0 );
close( fd );
temp = 'a';
for(i=0; i<10; i++)
{
temp += 1;
memcpy( ( *(p_map+i) ).name, &temp,2 );
( *(p_map+i) ).age = 20+i;
}
printf(" initialize over /n ");
sleep(10);
munmap( p_map, sizeof(people)*10 );
printf( "umap ok /n" );
}
/*-------------map_normalfile2.c-----------*/
#include <sys/mman.h>
#include <sys/types.h>
#include <fcntl.h>
#include <unistd.h>
typedef struct{
char name[4];
int age;
}people;
main(int argc, char** argv) // map a normal file as shared mem:
{
int fd,i;
people *p_map;
fd=open( argv[1],O_CREAT|O_RDWR,00777 );
p_map = (people*)mmap(NULL,sizeof(people)*10,PROT_READ|PROT_WRITE,MAP_SHARED,fd,0);
for(i = 0;i<10;i++)
{
printf( "name: %s age %d;/n",(*(p_map+i)).name, (*(p_map+i)).age );
}
munmap( p_map,sizeof(people)*10 );
}
map_normalfile1.c首先定义了一个people数据结构,(在这里采用数据结构的方式是因为,共享内存区的数据往往是有固定格式的,这由通信的各个进程决定,采用结构的方式有普遍代表性)。map_normfile1首先打开或创建一个文件,并把文件的长度设置为5个people结构大小。然后从mmap()的返回地址开始,设置了10个people结构。然后,进程睡眠10秒钟,等待其他进程映射同一个文件,最后解除映射。
map_normfile2.c只是简单的映射一个文件,并以people数据结构的格式从mmap()返回的地址处读取10个people结构,并输出读取的值,然后解除映射。
分别把两个程序编译成可执行文件map_normalfile1和map_normalfile2后,在一个终端上先运行./map_normalfile2 /tmp/test_shm,程序输出结果如下:
initialize over
umap ok
在map_normalfile1输出initialize over 之后,输出umap ok之前,在另一个终端上运行map_normalfile2 /tmp/test_shm,将会产生如下输出(为了节省空间,输出结果为稍作整理后的结果):
name: b age 20; name: c age 21; name: d age 22; name: e age 23; name: f age 24;
name: g age 25; name: h age 26; name: I age 27; name: j age 28; name: k age 29;
在map_normalfile1 输出umap ok后,运行map_normalfile2则输出如下结果:
name: b age 20; name: c age 21; name: d age 22; name: e age 23; name: f age 24;
name: age 0; name: age 0; name: age 0; name: age 0; name: age 0;
从程序的运行结果中可以得出的结论
1、 最终被映射文件的内容的长度不会超过文件本身的初始大小,即映射不能改变文件的大小;
2、 可以用于进程通信的有效地址空间大小大体上受限于被映射文件的大小,但不完全受限于文件大小。打开文件被截短为5个people结构大小,而在map_normalfile1中初始化了10个people数据结构,在恰当时候(map_normalfile1输出initialize over 之后,输出umap ok之前)调用map_normalfile2会发现map_normalfile2将输出全部10个people结构的值,后面将给出详细讨论。 注:在linux中,内存的保护是以页为基本单位的,即使被映射文件只有一个字节大小,内核也会为映射分配一个页面大小的内存。当被映射文件小于一个页面大小时,进程可以对从mmap()返回地址开始的一个页面大小进行访问,而不会出错;但是,如果对一个页面以外的地址空间进行访问,则导致错误发生,后面将进一步描述。因此,可用于进程间通信的有效地址空间大小不会超过文件大小及一个页面大小的和。
3、 文件一旦被映射后,调用mmap()的进程对返回地址的访问是对某一内存区域的访问,暂时脱离了磁盘上文件的影响。所有对mmap()返回地址空间的操作只在内存中有意义,只有在调用了munmap()后或者msync()时,才把内存中的相应内容写回磁盘文件,所写内容仍然不能超过文件的大小。
范例2:父子进程通过匿名映射实现共享内存
#include <sys/mman.h>
#include <sys/types.h>
#include <fcntl.h>
#include <unistd.h>
typedef struct{
char name[4];
int age;
}people;
main(int argc, char** argv)
{
int i;
people *p_map;
char temp;
p_map=(people*)mmap(NULL,sizeof(people)*10,PROT_READ|PROT_WRITE,MAP_SHARED|MAP_ANONYMOUS,-1,0);
if(fork() == 0)
{
sleep(2);
for(i = 0;i<5;i++)
printf("child read: the %d people's age is %d/n",i+1,(*(p_map+i)).age);
(*p_map).age = 100;
munmap(p_map,sizeof(people)*10); //实际上,进程终止时,会自动解除映射。
exit();
}
temp = 'a';
for(i = 0;i<5;i++)
{
temp += 1;
memcpy((*(p_map+i)).name, &temp,2);
(*(p_map+i)).age=20+i;
}
sleep(5);
printf( "parent read: the first people,s age is %d/n",(*p_map).age );
printf("umap/n");
munmap( p_map,sizeof(people)*10 );
printf( "umap ok/n" );
}
考察程序的输出结果,体会父子进程匿名共享内存:
child read: the 1 people's age is 20
child read: the 2 people's age is 21
child read: the 3 people's age is 22
child read: the 4 people's age is 23
child read: the 5 people's age is 24
parent read: the first people,s age is 100
umap
umap ok
四、对mmap()返回地址的访问
前面对范例运行结构的讨论中已经提到,linux采用的是页式管理机制。对于用mmap()映射普通文件来说,进程会在自己的地址空间新增一块空间,空间大小由mmap()的len参数指定,注意,进程并不一定能够对全部新增空间都能进行有效访问。进程能够访问的有效地址大小取决于文件被映射部分d Settings/dangxin/My Documents/My Pictures/jincheng.JPG的大小。简单的说,能够容纳文件被映射部分大小的最少页面个数决定了进程从mmap()返回的地址开始,能够有效访问的地址空间大小。超过这个空间大小,内核会根据超过的严重程度返回发送不同的信号给进程。可用如下图示说明:
注意:文件被映射部分而不是整个文件决定了进程能够访问的空间大小,另外,如果指定文件的偏移部分,一定要注意为页面大小的整数倍。下面是对进程映射地址空间的访问范例:
#include <sys/mman.h>
#include <sys/types.h>
#include <fcntl.h>
#include <unistd.h>
typedef struct{
char name[4];
int age;
}people;
main(int argc, char** argv)
{
int fd,i;
int pagesize,offset;
people *p_map;
pagesize = sysconf(_SC_PAGESIZE);
printf("pagesize is %d/n",pagesize);
fd = open(argv[1],O_CREAT|O_RDWR|O_TRUNC,00777);
lseek(fd,pagesize*2-100,SEEK_SET);
write(fd,"",1);
offset = 0;
//此处offset = 0编译成版本;offset = pagesize编译成版本
p_map = (people*)mmap(NULL,pagesize*3,PROT_READ|PROT_WRITE,MAP_SHARED,fd,offset);
close(fd);
for(i = 1; i<10; i++)
{
(*(p_map+pagesize/sizeof(people)*i-2)).age = 100;
printf("access page %d over/n",i);
(*(p_map+pagesize/sizeof(people)*i-1)).age = 100;
printf("access page %d edge over, now begin to access page %d/n",i, i+1);
(*(p_map+pagesize/sizeof(people)*i)).age = 100;
printf("access page %d over/n",i+1);
}
munmap(p_map,sizeof(people)*10);
}
如程序中所注释的那样,把程序编译成两个版本,两个版本主要体现在文件被映射部分的大小不同。文件的大小介于一个页面与两个页面之间(大小为:pagesize*2-99),版本1的被映射部分是整个文件,版本2的文件被映射部分是文件大小减去一个页面后的剩余部分,不到一个页面大小(大小为:pagesize-99)。程序中试图访问每一个页面边界,两个版本都试图在进程空间中映射pagesize*3的字节数。
版本1的输出结果如下:
pagesize is 4096
access page 1 over
access page 1 edge over, now begin to access page 2
access page 2 over
access page 2 over
access page 2 edge over, now begin to access page 3
Bus error //被映射文件在进程空间中覆盖了两个页面,此时,进程试图访问第三个页面
版本2的输出结果如下:
pagesize is 4096
access page 1 over
access page 1 edge over, now begin to access page 2
Bus error //被映射文件在进程空间中覆盖了一个页面,此时,进程试图访问第二个页面
结论:采用系统调用mmap()实现进程间通信是很方便的,在应用层上接口非常简洁。内部实现机制区涉及到了linux存储管理以及文件系统等方面的内容,可以参考一下相关重要数据结构来加深理解。