Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Binder | 内存拷贝的本质和变迁 - 芦半山 - 稀土掘金 #46

Open
cyrushine opened this issue Dec 30, 2022 · 1 comment
Open

Comments

@cyrushine
Copy link
Owner

https://juejin.cn/post/6844904113046568973


进程A通过 binder_ transaction 往进程B传输数据的过程,其中步骤 4、5、6 和 7 是一页一页地循环执行:

  1. 准备需要发送的数据, 数据存放在进程A的用户空间中

  2. 进程A通过系统调用进入binder驱动的代码逻辑中

  3. 在binder驱动代码中, 找到与所发送数据大小匹配的进程B的 binder buffer, 该 buffer 此时只有虚拟地址(虚拟地址指向进程B的用户空间), 尚未映射物理页

  4. 为此次数据传输分配物理页

  5. 建立 binder buffer 中虚拟页与物理页的映射关系

  6. 通过 kmap 为物理页映射一个内核空间的虚拟页

  7. copy_ from_user 将进程A用户空间的数据写入刚刚映射的内核空间虚拟页中

  8. 根据映射关系, 刚刚写入的数据现在可以通过进程B binder buffer的地址读出


说起Binder的内存拷贝,相信大多数人都听过“一次拷贝”:相较于传统IPC的两次拷贝,Binder在数据传输时显得效率更高。
其实不少人在面试时都能回答出上面这句话,但若是追问他更多细节,估计又哑口无言了。
其实内存拷贝的概念既简单又复杂。简单是因为它功能单一,而复杂则在于不少人对于虚拟内存,物理内存,用户空间,内核空间的认识并不充分。所谓地基不稳,高楼难立。
本文尝试揭示Binder内存拷贝的本质,另外还会介绍新版本中相应实现的一些改动。

内存拷贝概述

在做任何一件事之前,先明确目的。我相信Binder的开发者在最初设计时也一定仔细考虑过这个问题。根据我的理解,Binder数据传输的目的可以概括成这句话:

一个进程可以通过自己用户空间的虚拟地址访问另一个进程的数据。

要想充分理解这句话,需要在基础知识上达成一些共识。

虚拟地址和数据的关系

所有的数据都存储在物理内存中,而进程访问内存只能通过虚拟地址。因此,若是想成功访问必须得有个前提:

虚拟地址和物理内存之间建立映射关系

若是这层映射关系不建立,则访问会出错。信号11(SIGSEGV)的MAPERR就是专门用来描述这种错误的。
虚拟地址和物理地址间建立映射关系通过mmap完成。这里我们不考虑file-back的mapping,只考虑anonymous mapping。当mmap被调用(flag=MAP_ANONYMOUS)时,实际上会做以下两件事:

  1. 分配一块连续的虚拟地址空间。
  2. 更新这些虚拟地址对应的PTE(Page Table Entry)。

mmap做完这两件事后,就会返回连续虚拟地址空间的起始地址。在mmap调用结束后,其实并不会立即分配物理页。如果此时不分配物理页,那么就会有如下两个问题:

  1. 没有新的物理页分配,那么PTE都更新了哪些内容?
  2. 如果后续使用mmap返回的虚拟地址访问内存,会有什么情况产生呢?

没有新的物理页分配,那么PTE都更新了些什么内容呢?

PTE也即页表的条目,它的内容反映了一个虚拟地址到物理地址之间的映射关系。如果没有新的物理页分配,那这些新的虚拟地址都和哪些物理地址之间建立了映射关系呢?答案是所有的虚拟地址都和同一个zero page(页内容全为0)建立了映射关系。

如果后续使用mmap返回的虚拟地址访问内存,会有什么情况产生呢?

拿到mmap返回的虚拟地址后,并不会有新的物理页分配。此时若是直接读取虚拟地址中的值,则会通过PTE追踪到刚刚建立映射关系的zero page,因此读取出来的值都是0。
如果此时往虚拟地址中写入数据,将会在page fault handler中触发一个正常的copy-on-write机制。需要写多少页,就会新分配多少物理页。所以我们可以看到,真实的物理页是符合lazy(on-demand) allocation原则的。这一点,极大地保证了物理资源的合理分配和使用。

进程间用户空间/内核空间是否隔离?

先说结论,不同进程间的用户空间是完全隔离的,内核空间是共享的。
那么“隔离”和“共享”在这个语境下又是什么意思呢?
从实现角度而言,“隔离”的意思是不同进程的页表不同,“共享”的意思是不同进程的页表相同,仅此而已。我们知道,页表反映的是虚拟地址和物理地址的映射关系。那么一张页表应该管理哪些虚拟地址呢?是整个地址空间的所有虚拟地址么?
当然不是。Linux将虚拟地址空间分为了用户空间和内核空间,因此管理不同空间虚拟地址的页表也不一样。

image

如上图所示,A进程的用户空间使用页表1,B进程的用户空间使用页表2,而A/B进程的内核空间都使用页表3。A/B中使用相同的用户空间虚拟地址来访问内存,由于页表不同,因此最终映射的物理页也不同,这就是所谓的“进程隔离”。而由于A/B进程的内核空间使用了同一张页表,所以只要他们使用相同的虚拟地址(位于内核空间),那么必然访问到同一个物理页。

数据传输的两种方式

共享内存

虚拟地址只是为了进行内存访问封装的一层接口,而数据总归是存在物理内存上的。因此,若是想让A进程通过(用户空间)虚拟地址访问到B进程中的数据,最高效的方式就是修改A/B进程中某些虚拟地址的PTE,使得这些虚拟地址映射到同一片物理区域。这样就不存在任何拷贝,因此数据在物理空间中也只有一份。

内存拷贝

共享内存虽然高效,但由于物理内存只有一份,因此少不了考虑各种同步机制。让不同进程考虑数据的同步问题,这对于Android而言是个挑战。因为作为系统平台,它必然希望降低开发者的开发难度,最好让开发者只用管好自己的事情。因此,让开发者频繁地考虑跨进程数据同步问题不是一个好的选择。

取而代之的是内存拷贝的方法。该方法可以保证不同进程都拥有一块属于自己的数据区域,该区域不用考虑进程间的数据同步问题。
由于不同进程的内核空间是共享的(只有共享才能完成传输,否则只能隔江相望了),因此很自然地考虑到将它作为数据中转站。常规的做法需要两次拷贝,一次是由发送进程的用户空间拷贝到发送进程的内核空间,另一次是由接收进程的内核空间拷贝到接收进程的用户空间。这两次拷贝中间有一个隐含的转换关系,即发送进程的内核空间和接收进程的内核空间是共享的,因此持有相同的虚拟地址就会访问到同一片物理区域。

两次拷贝的方法比较符合直觉,但在效率上还有可优化的空间。
既然两次拷贝都发生在一个进程的用户空间和内核空间之间,那么其实也就隐含了一个前提:

用户空间和内核空间的虚拟地址指向不同的物理页。

正是因为指向不同的物理页,所以才需要拷贝。那有没有可能让二者指向同一个物理页?如果可以,这样不就节省了一次拷贝么?
事实上,Binder正是这样做的。

Binder内存拷贝的实现

早期版本(≤Android P)

为了减少一次拷贝,接收数据的进程必须同时满足下面三个条件:

  1. 在用户空间分配一块连续区域A(仅仅是虚拟地址的分配)。
  2. 在内核空间分配一块同样大小的连续区域B(同样仅仅是虚拟地址的分配)。
  3. 在每次数据通信的时候,根据实际需求分配物理页,并将该物理页同时映射到A/B中偏移相同的位置。

条件1、2在进程调用Binder的mmap函数时已经完成,而条件3则在每次数据通信时进行。
下面假设进程1发送数据,进程2接收数据,我们来分析下内存拷贝到底发生在何时(以下执行均发生在进程1中,只不过此时正在执行驱动代码[陷入内核态])。

  1. 由于进程2之前调用过mmap函数(只会调用一次),因此它拥有用户空间的区域A和内核空间的区域B(只分配了虚拟地址,并未映射物理页)。
  2. 得知即将发送的数据大小,并根据该大小分配实际的物理页。
  3. 将刚刚分配出来的物理页映射到进程2的A/B区域中(由于进程1处于内核态,因此可以操作进程2的PTE)。
  4. 将用户空间的发送数据通过copy_from_user拷贝到内核区域B中。
  5. 由于A/B映射到同样的物理页,因此B中的数据也可以通过A的地址读取出来。

整个过程中,只有步骤4发生了一次数据拷贝。

当前版本(Android Q,R)

从性能角度而言,早期版本的实现几乎无可挑剔。但是它有一个致命的稳定性缺陷,这是Google工程师们无法忍受的。因此从Android Q开始,Binder内存拷贝的实现有了新的改动。
通过之前的分析可以知道,驱动的mmap函数执行完之后,该进程将会在内核空间分配一块虚拟地址区域B。对Android应用进程而言,B的默认大小为1M-8K。只要这个进程没有退出,这1M-8K的虚拟地址就会一直分配给它。
通常对于虚拟地址长时间的占用并不会产生问题,但不幸的是,Binder的这个占用确实产生了问题。

32位机器上Binder内存拷贝的缺陷

32位机器的寻址空间为4G,其中高位的1G用作内核地址空间,低位的3G用作用户地址空间,这些都是虚拟地址的概念。
1G的内核地址空间又划分为四块不同的区域:

  • 直接映射区(Direct memory region),该区域的虚拟地址和物理地址上的低端内存直接映射,因此虚拟地址和物理地址之间永远差一个固定的偏移。kmalloc分配的地址就位于此块区域。
  • vmalloc区,该区域的虚拟地址可以映射到物理地址上的高端内存。由于高端内存的地址范围远大于vmalloc区域的地址范围,因此二者之间的映射不能采用线性映射,只能是动态映射。vmalloc分配的地址就位于此块区域。
  • 临时映射区(Kmap region),4M的固定大小,主要用于先有物理页而后需要为其分配内核空间地址的情况。调用一次kmap只能映射一页,常用于短时间映射的场景。
  • 固定映射区(Fixed mapping region)

vmalloc区的大小随着Kernel版本的不同也发生过变化。从Kernel 3.13开始,vmalloc区域由128M增加到240M。240M看似是个不小的数字,但在应用启动过多的手机上将会出问题。此话怎讲?
随着Android Treble项目(Android O引入)的启动,hardware binder正式进入大众视野。一方面越来越多的HAL service使用hwbinder进行跨进程通信,另一方面原先只需分配1M-8K的应用进程现在需要多分配一块区域用于hwbinder通信。因此,binder驱动对于内核空间vmalloc区域的占用成倍地上升。当应用启动过多时,vmalloc区域的虚拟地址将有可能被耗尽。注意,这里指的是虚拟地址被耗尽,而不是物理地址被耗尽。
当vmalloc区域的虚拟地址被耗尽时,内核中某些使用vmalloc和vmap的代码将会报错,因为他们此时分配不出新的虚拟地址。
为了缓解这个问题,一个简单的想法自然就是增大vmalloc区域。但是1G的内核空间是固定的,厚此必定薄彼。vmalloc区域增大,意味着直接映射区减少。而直接映射区一个最大的好处就是高效(因为采用了线性映射),所以不能被无限制缩小。因此增大vmalloc区域的做法只能算是缓兵之计,绝非最佳策略。

新的实现

让我们回到最初的目的,仔细思考内核空间虚拟地址存在的意义。
其实,它只是内核空间中我们为物理页找的访问入口而已,它既没有一直存在的必要,也不会有后续使用的价值。一旦数据传输完毕,这个入口也就失去了意义。
既然如此,我们何不采用一种更加动态的方式,在每次传输之前分配这个入口,传输完成后再释放这个入口?
事实上新版本的Binder就是这么做的。

image

上图右边的文字展示了一次完整数据传输所经历的过程。早期版本的Binder通过一次copy_from_user将数据整体拷贝完成,新版本的Binder则通过循环调用copy_from_user将数据一页一页的拷贝完成。以下是核心代码差异的展示:

Android version ≤ P:

1501	if (copy_from_user(t->buffer->data, (const void __user *)(uintptr_t)
1502			   tr->data.ptr.buffer, tr->data_size)) {
1503		binder_user_error("%d:%d got transaction with invalid data ptr\n",
1504				proc->pid, thread->pid);
1505		return_error = BR_FAILED_REPLY;
1506		goto err_copy_data_failed;
1507	}
1508	if (copy_from_user(offp, (const void __user *)(uintptr_t)
1509			   tr->data.ptr.offsets, tr->offsets_size)) {
1510		binder_user_error("%d:%d got transaction with invalid offsets ptr\n",
1511				proc->pid, thread->pid);
1512		return_error = BR_FAILED_REPLY;
1513		goto err_copy_data_failed;
1514	}

Android version ≥ Q:

1108 	while (bytes) {
1109 		unsigned long size;
1110 		unsigned long ret;
1111 		struct page *page;
1112 		pgoff_t pgoff;
1113 		void *kptr;
1114 
1115 		page = binder_alloc_get_page(alloc, buffer,
1116 					     buffer_offset, &pgoff);
1117 		size = min_t(size_t, bytes, PAGE_SIZE - pgoff);
1118 		kptr = kmap(page) + pgoff;
1119 		ret = copy_from_user(kptr, from, size);
1120 		kunmap(page);
1121 		if (ret)
1122 			return bytes - size + ret;
1123 		bytes -= size;
1124 		from += size;
1125 		buffer_offset += size;
1126 	}

可以看到在新版本的实现中,每拷贝一页的内容就调用一次kunmap将分配的内核空间虚拟地址释放掉。这样就再也不会发生长时间占用内核空间虚拟地址的情况

@cyrushine
Copy link
Owner Author

cyrushine commented Dec 27, 2023

image

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant