1.
消息队列和管道都是采用存储-转发方式,使用它们进行IPC通信时,需要经过2次内存拷贝!效率太低!
2. 为什么消息队列和管道的数据传输需要经过2次内存拷贝呢? 首先,数据先从发送方的缓存区(即,Linux中的用户存储空间)拷贝到内核开辟的缓存区(即,Linux中的内核存储空间)中,是第1次拷贝。接着,再从内核缓存区拷贝到接收方的缓存区(也是Linux中的用户存储空间),这是第2次拷贝。
3. Linux进程间通信的方式有传统的管道(Pipe)、信号(Signal)和跟踪(Trace),这三项通信手段只能用于父进程与子进程之间,或者兄弟进程之间;后来又增加了命令管道(Named Pipe),使得进程间通信不再局限于父子进程或者兄弟进程之间;为了更好地支持商业应用中的事务处理,在AT&T的Unix系统V中,又增加了三种称为“System V IPC”的进程间通信机制,分别是报文队列(Message)、共享内存(Share Memory)和信号量(Semaphore);后来BSD Unix对“System V IPC”机制进行了重要的扩充,提供了一种称为插口(Socket)的进程间通信机制。
1. Liunx 中跨进程通信的一些基本概念
上图展示了 Liunx 中跨进程通信涉及到的一些基本概念:
1. 进程隔离
2. 进程空间划分:用户空间(User Space)/内核空间(Kernel Space)
3. 系统调用:用户态/内核态
² 进程隔离
简单的说就是操作系统中,进程与进程间内存是不共享的。两个进程就像两个平行的世界,A 进程没法直接访问 B 进程的数据,这就是进程隔离的通俗解释。A 进程和 B 进程之间要进行数据交互就得采用特殊的通信机制:进程间通信(IPC)。
² 进程空间划分:用户空间(User Space)/内核空间(Kernel Space)
现在操作系统都是采用的虚拟存储器,对于 32 位系统而言,它的寻址空间(虚拟存储空间)就是 2 的 32 次方,也就是 4GB。操作系统的核心是内核,独立于普通的应用程序,可以访问受保护的内存空间,也可以访问底层硬件设备的权限。为了保护用户进程不能直接操作内核,保证内核的安全,操作系统从逻辑上将虚拟空间划分为用户空间(User Space)和内核空间(Kernel Space)。针对 Linux 操作系统而言,将最高的 1GB 字节供内核使用,称为内核空间;较低的 3GB 字节供各进程使用,称为用户空间。
简单的说就是,内核空间(Kernel)是系统内核运行的空间,用户空间(User Space)是用户程序运行的空间。为了保证安全性,它们之间是隔离的。
² 系统调用:用户态与内核态
虽然从逻辑上进行了用户空间和内核空间的划分,但不可避免的用户空间需要访问内核资源,比如文件操作、访问网络等等。为了突破隔离限制,就需要借助系统调用来实现。系统调用是用户空间访问内核空间的唯一方式,保证了所有的资源访问都是在内核的控制下进行的,避免了用户程序对系统资源的越权访问,提升了系统安全性和稳定性。
Linux 使用两级保护机制:0 级供系统内核使用,3 级供用户程序使用。
当一个任务(进程)执行系统调用而陷入内核代码中执行时,称进程处于内核运行态(内核态)。此时处理器处于特权级最高的(0级)内核代码中执行。当进程处于内核态时,执行的内核代码会使用当前进程的内核栈。每个进程都有自己的内核栈。
当进程在执行用户自己的代码的时候,我们称其处于用户运行态(用户态)。此时处理器在特权级最低的(3级)用户代码中运行。
系统调用主要通过如下两个函数来实现:
copy_from_user() //将数据从用户空间拷贝到内核空间
copy_to_user() //将数据从内核空间拷贝到用户空间
2. Linux 下的传统 IPC 通信原理
通常的做法是消息发送方将要发送的数据存放在内存缓存区中,通过系统调用进入内核态。然后内核程序在内核空间分配内存,开辟一块内核缓存区,调用 copy_from_user() 函数将数据从用户空间的内存缓存区拷贝到内核空间的内核缓存区中。同样的,接收方进程在接收数据时在自己的用户空间开辟一块内存缓存区,然后内核程序调用 copy_to_user() 函数将数据从内核缓存区拷贝到接收进程的内存缓存区。这样数据发送方进程和数据接收方进程就完成了一次数据传输,我们称完成了一次进程间通信。如下图:
这种传统的 IPC 通信方式有两个问题:
² 性能低下,一次数据传递需要经历:内存缓存区 --> 内核缓存区 --> 内存缓存区,需要 2 次数据拷贝;
² 接收数据的缓存区由数据接收进程提供,但是接收进程并不知道需要多大的空间来存放将要传递过来的数据,因此只能开辟尽可能大的内存空间或者先调用 API 接收消息头来获取消息体的大小,这两种做法不是浪费空间就是浪费时间。
3. 内存映射
Binder IPC 机制中涉及到的内存映射通过 mmap() 来实现,mmap() 是操作系统中一种内存映射的方法。内存映射简单的讲就是将用户空间的一块内存区域映射到内核空间。映射关系建立后,用户对这块内存区域的修改可以直接反应到内核空间;反之内核空间对这段区域的修改也能直接反应到用户空间。
内存映射能减少数据拷贝次数,实现用户空间和内核空间的高效互动。两个空间各自的修改能直接反映在映射的内存区域,从而被对方空间及时感知。也正因为如此,内存映射能够提供对进程间通信的支持。
Binder IPC 正是基于内存映射(mmap)来实现的,但是 mmap() 通常是用在有物理介质的文件系统上的。
比如进程中的用户区域是不能直接和物理设备打交道的,如果想要把磁盘上的数据读取到进程的用户区域,需要两次拷贝(磁盘-->内核空间-->用户空间);通常在这种场景下 mmap() 就能发挥作用,通过在物理介质和用户空间之间建立映射,减少数据的拷贝次数,用内存读写取代I/O读写,提高文件读取效率。
而 Binder 并不存在物理介质,因此 Binder 驱动使用 mmap() 并不是为了在物理介质和用户空间之间建立映射,而是用来在内核空间创建数据接收的缓存空间。
一次完整的 Binder IPC 通信过程通常是这样:
1. 首先 Binder 驱动在内核空间创建一个数据接收缓存区;
2. 接着在内核空间开辟一块内核缓存区,建立内核缓存区和内核中数据接收缓存区之间的映射关系,以及内核中数据接收缓存区和接收进程用户空间地址的映射关系;
3.
发送方进程通过系统调用
copy_from_user() 将数据 copy 到内核中的内核缓存区,由于内核缓存区和接收进程的用户空间存在内存映射,因此也就相当于把数据发送到了接收进程的用户空间,这样便完成了一次进程间的通信。
4. 进程间通信的本质
每个进程各自有不同的用户地址空间,任何一个进程的全局变量在另一个进程中都看不到,所以进程之间要交换数据必须通过内核。在内核中开辟一块缓冲区,进程A把数据从用户空间拷到内核缓冲区,进程B再从内核缓冲区把数据读走,内核提供的这种机制称为进程间通信。
不同进程间的通信本质:进程之间可以看到一份公共资源;而提供这份资源的形式或者提供者不同,造成了通信方式不同,而 pipe就是提供这份公共资源的形式的一种。
Android是基于Linux内核而打造的操作系统。以32位Linux系统而言,它的内存最大是4G。在这4G内存中,0~3G为用户空间,3~4G为内核空间。应用程序都运行在用户空间,而Kernel和驱动都运行在内核空间。用户空间和内核空间若涉及到通信(即,数据交互),两者不能简单地使用指针传递数据,而必须在"内核"中通过copy_from_user(),copy_to_user(),get_user()或put_user()等函数传递数据。copy_to_user()和put_user()是将内核空间的数据拷贝到用户空间,而copy_from_user()和get_user()则是将用户空间的数据拷贝到内核空间。
进程拥有独立的内存单元,它是系统进行资源分配和调度的基本单位。对于Linux系统而言,每一个运行在用户空间的应用程序都可以看作一个进程。不同的进程在不同的内存中,因此当一个程序崩溃之后,不会对其它的程序造成影响。
应用程序都运行在用户空间,每个应用程序都有它自己独立的内存空间;若不同的应用程序之间涉及到通信,需要通过内核进行中转,因为需要用到内核的copy_from_user()和copy_to_user()等函数。
ServiceManager, MediaPlayerService和MediaPlayer都位于用户空间,它们是不同的进程。Binder机制的最终目的是实现"MediaPlayerService和MediaPlayer这两个不同进程之间的通信"。而这两个不同进程的通信必须要内核进行中转,对于Android而言,在内核中起中转作用便是Binder驱动。
5. 进程间通信的方式
本地的进程间通信(IPC)有很多种方式,但可以总结为下面4类:
l 消息传递(管道、FIFO、消息队列)
l 同步(互斥量、条件变量、读写锁、文件和写记录锁、信号量)
l 共享内存(匿名的和具名的)
l 远程过程调用(Solaris门和Sun RPC)
进程通信,AIDL (自定义binder, 同步) 和 Messager(message binder,异步),双向通信实现。
其他使用ContentProvider(比较重),BroadCastReceiver(会失效),LocalSocket (有限制,Java与C之间通信)等等
6. 管道的通信原理和特点
管道是由调用pipe函数来创建
#include <unistd.h>
int pipe (int fd[2]); //返回:成功返回0,出错返回-1
fd参数返回两个文件描述符,fd[0]指向管道的读端,fd[1]指向管道的写端。fd[1]的输出是fd[0]的输入。
管道通信的方式如下:
² 父进程创建管道,得到两个文件描述符指向管道的两端。(Unix哲学,一切皆文件)
² 父进程fork出子进程,子进程也有两个文件描述符指向同一管道。
² 父进程关闭fd[0],子进程关闭fd[1],即父进程关闭管道读端,子进程关闭管道写端(因为管道只支持单向通信)。父进程可以往管道里写,子进程可以从管道里读。管道是用环形队列实现的,数据从写端流入从读端流出,这样就实现了进程间通信。
² 如果写的时候,管道里写满了数据,写会被阻塞。读的时候,如果管道为空,读会被阻塞。管道的容量大小为64k,超出了写入就会阻塞。
1. 管道只允许具有血缘关系的进程间通信,如父子进程间的通信(parent fork出来的child进程)。
2. 管道只允许单向通信。
3. 管道内部保证同步机制,从而保证访问数据的一致性。
4. 面向字节流
5. 管道是否存在取决于随进程是否存在。进程在管道在,进程消失管道对应的端口也关闭,两个进程都消失管道也消失。
7. 消息队列
消息队列是消息的链接表,存放在内核中并由消息队列标识符标识。 标识符是IPC对象的内部名,而它的外部名则是key(键)。它的基本类型是key_t,在头文件<sys/types.h>中定义为长整型.。键由内核变换成标识符。
用户可以从消息队列中读取数据和添加消息,其中发送进程添加消息到队列的末尾,接收进程在队列的头部接收消息,消息一旦被接收,就会从队列中删除。和FIFO有点类似,但是它可以实现消息的随机查询,比FIFO具有更大的优势(比如按消息的类型字段取消息)。
8. 信号量的工作原理
信号量本质上是一个计数器(不设置全局变量是因为进程间是相互独立的,而这不一定能看到,看到也不能保证++引用计数为原子操作),用于多进程对共享数据对象的读取,它和管道有所不同,它不以传送数据为主要目的,它是用来多进程间(或多线程)进行同步,保护共享资源(信号量也属于临界资源),使得资源在一个时刻只有一个进程独享。
为了防止出现因多个程序同时访问一个共享资源而引发的一系列问题,我们需要一种方法,它可以通过生成并使用令牌来授权,在任一时刻只能有一个执行线程访问代码的临界区域。临界区域是指执行数据更新的代码需要独占式地执行。而信号量就可以提供这样的一种访问机制,让一个临界区同一时间只有一个线程在访问它,也就是说信号量是用来调协进程对共享资源的访问的。
信号量是一个特殊的变量,程序对其访问都是原子操作,且只允许对它进行等待(即P(信号变量))和发送(即V(信号变量))信息操作。最简单的信号量是只能取0和1的变量,这也是信号量最常见的一种形式,叫做二进制信号量。而可以取多个正整数的信号量被称为通用信号量。这里主要讨论二进制信号量。
由于信号量只能进行两种操作等待和发送信号,即P(sv)和V(sv),他们的行为是这样的:
P(sv):如果sv的值大于零,就给它减1;如果它的值为零,就挂起该进程的执行
V(sv):如果有其他进程因等待sv而被挂起,就让它恢复运行,如果没有进程因等待sv而挂起,就给它加1.
在信号量进行PV操作时都为原子操作(因为它需要保护临界资源)。原子操作是指执行不会被中断的操作。
举个例子,就是两个进程共享信号量sv,一旦其中一个进程执行了P(sv)操作,它将得到信号量,并可以进入临界区,使sv减1。而第二个进程将被阻止进入临界区,因为当它试图执行P(sv)时,sv为0,它会被挂起以等待第一个进程离开临界区域并执行V(sv)释放信号量,这时第二个进程就可以恢复执行。
9. 什么是共享内存?
共享内存就是允许两个或多个进程共享一定的存储区。就如同 malloc() 函数向不同进程返回了指向同一个物理内存区域的指针。当一个进程改变了这块地址中的内容的时候,其它进程都会察觉到这个更改。因为数据不需要在客户机和服务器端之间复制,数据直接写到内存,不用若干次数据拷贝,所以这是最快的一种IPC。
注:共享内存没有任何的同步与互斥机制,所以要使用信号量来实现对共享内存的存取的同步。
共享内存中的数据并不会像管道或者信号量等被一端读取之后就不存在。
² 使用共享内存的优缺点
优点:我们可以看到使用共享内存进行进程间的通信真的是非常方便,而且函数的接口也简单,数据的共享还使进程间的数据不用传送,而是直接访问内存,也加快了程序的效率。同时,它也不像匿名管道那样要求通信的进程有一定的父子关系。
缺点:共享内存没有提供互斥同步的机制,这使得我们在使用共享内存进行进程间通信时,往往要借助其他的手段比如信号量等来进行进程间的同步工作。
10. Socket的三种类型和LocalSocket
对于Unix系统来说,“一切皆为文件”,Socket也不例外,Socket按照收发双方的媒介来说有三种类型:1,通过网络端口;2,通过文件系统;3,通过内存映射文件。具体说来,三种类型均可以用来作为IPC的Socket:
1,通过本地回环接口(即LoopBack)127.0.0.1来收发数据;
2,通过文件作为收发数据的中转站;
3,在内存中开辟一块区域作为收发数据的中转站,此区域仍然使用文件读写API进行访问。
LocalSocket支持方式2和方式3,从效率的角度来说,显然是方式3效率最高,那么下面我们就使用LocalSocket来演示如何实现Java端进程与C端进程之间的IPC。
在Android API中,有几个类对localsocket进行了封装,不仅可以用来应用程序之间进行IPC通信,还可以跨应用程序层和Linux层运行的程序进行通信。
Android本身就大量使用到了Localsocket进行跨系统层次的通信,对Localsocket的一些常用操作进行了封装。
11. 定义多进程
Android应用中使用多进程只有一个办法(用NDK的fork来做除外),就是在AndroidManifest.xml中声明组件时,用android:process属性来指定。
不知定process属性,则默认运行在主进程中,主进程名字为包名。
android:process = remote,将运行在remote进程中,属于全局进程,其他具有相同shareUID与签名的APP可以跑在这个进程中。
android:process = :remote ,将运行在默认包名:remote进程中,而且是APP的私有进程,不允许其他APP的组件来访问。
也就是说android:process=":remote",代表在应用程序里,当需要该service时,会自动创建新的进程。而如果是android:process="remote",没有“:”分号的,则创建全局进程,不同的应用程序共享该进程。
在AndroidManifest.xml中的配置 android:process:
这里选择“remote”这个名字是随意主观的,你也可以取其他的名字。冒号“:”则代替当前应用的包名,所以MyService跑在进程名为“com.example.liuwangshu.myprogress:remote”的进程中。
我们也可以设置 android:process=”com.example.liuwangshu.myprogress.remote”,这样MyService跑在进程名为“com.example.liuwangshu.myprogress.remote”的进程中。
这两种命名也是有区别的,如果被设置的进程名是以一个冒号开头的,则这个新的进程对于这个应用来说是私有的,当它被需要或者这个服务需要在新进程中运行的时候,这个新进程将会被创建。如果这个进程的名字是以小写字符开头的,则这个服务将运行在一个以这个名字命名的全局的进程中,当然前提是它有相应的权限。这将允许在不同应用中的各种组件可以共享一个进程,从而减少资源的占用。
我们运行应用则发现:开启了两个进程
12. 多进程引发的问题
静态成员和单例失效:每个进程保持各自的静态成员和单例,相互独立。
线程同步机制失效:每个进程有自己的线程锁。
SharedPreferences可靠性下降:不支持并发写,会出现脏数据。
Application多次创建:不同进程跑在不同虚拟机,每个虚拟机启动会创建自己的Application,自定义Application时生命周期会混乱。
综上,不同进程拥有各自独立的虚拟机,Application,内存空间,由此引发一系列问题。下面即是一个多进程引发的问题。