背景
某项目有对adb install优化的一个需求,项目的平台是Android 10,内核版本是4.19, Data分区是F2FS文件系统。由于adb install是Android一个很标准的流程,网上有很多详细的介绍,本文不涉及这个具体流程,感兴趣的可以参考一下应用程序安装流程。
Adb install简单的流程是这样的,首先把安装包从PC传到设备中,然后再在设备中执行安装操作。本文只涉及安装流程中的第一个环节,即把安装文件从PC通过USB传到设备本地文件系统中的这个拷贝过程,这个过程耗时随着安装包的变大而变长。
文件复制代码
在执行adb install的过程中把安装包从PC通过USB复制到本地文件系统中,这个过程会调用到一个copy函数,这个函数的输入是2个文件的FD,输入FD对应的是从USB传来的源文件,输出文件对应的是要保存的本地临时文件。这个函数在frameworks/base/core/java/android/os/FileUtils.java里面。
/**
* Copy the contents of one FD to another.
* <p>
* Attempts to use several optimization strategies to copy the data in the
* kernel before falling back to a userspace copy as a last resort.
*
* @param count the number of bytes to copy.
* @param signal to signal if the copy should be cancelled early.
* @param executor that listener events should be delivered via.
* @param listener to be periodically notified as the copy progresses.
* @return number of bytes copied.
* @hide
*/
public static long copy(@NonNull FileDescriptor in, @NonNull FileDescriptor out, long count,
@Nullable CancellationSignal signal, @Nullable Executor executor,
@Nullable ProgressListener listener) throws IOException {
if (sEnableCopyOptimizations) {
try {
final StructStat st_in = Os.fstat(in);
final StructStat st_out = Os.fstat(out);
if (S_ISREG(st_in.st_mode) && S_ISREG(st_out.st_mode)) {
return copyInternalSendfile(in, out, count, signal, executor, listener);
} else if (S_ISFIFO(st_in.st_mode) || S_ISFIFO(st_out.st_mode)) {
return copyInternalSplice(in, out, count, signal, executor, listener);
}
} catch (ErrnoException e) {
throw e.rethrowAsIOException();
}
}
// Worse case fallback to userspace
return copyInternalUserspace(in, out, count, signal, executor, listener);
}
看这个函数的代码,可以得到以下信息
-
copy是一个单纯函数,就是根据传入的输入输出FD,完成文件复制标准操作。
-
Google根据FD的类型,进行了相应优化操作
-
如果输入输出都是普通文件,则调用copyInternalSendfile
-
如果输入输出有一个是FIFO文件,则调用copyInternalSplice
-
其他情况,则调用copyInternalUserspace
-
下面会对这几种情况进一步分析,在分析之前先了解一下零拷贝技术。
零拷贝技术
零拷贝(Zero-copy)技术指在计算机执行I/O操作时,CPU 不需要先将数据从一个内存区域复制到另一个内存区域。相比于标准I/O,零拷贝并不是没有拷贝数据,而是减少上下文切换以及CPU拷贝。零拷贝机制的好处:
-
减少数据在内核缓冲区和用户进程缓冲区之间反复的 I/O 拷贝操作
-
减少用户进程地址空间和内核地址空间之间的上下文切换
-
实现 CPU 的零参与,消除 CPU 在这方面的负载
零拷贝的实现主要有以下几种:
-
mmap+write
-
sendfile+DMA scatter/gather
-
splice
本文不再涉及零拷贝的更多原理介绍,想了解更多信息,可以参考IO 零拷贝 - 掘金
零拷贝的应用
有了对零拷贝的基础知识的了解,可以知道零拷贝固然好但是使用起来有各种限制,在adb install这个场景下,输入的FD是一个Socket,输出的FD是一个普通文件,并不能直接采用任何一种零拷贝技术,那如何来优化呢?下面先进一步看一下本例中涉及到的几个函数。
copyInternalSendfile
copyInternalSendfile实际上就是采用了零拷贝技术之一,sendfile系统调用。这个函数要求是输入输出都是普通文件,本场景不满足,所有不会进入这个分支。
/**
* Requires both input and output to be a regular file.
*
* @hide
*/
@VisibleForTesting
public static long copyInternalSendfile(FileDescriptor in, FileDescriptor out, long count,
CancellationSignal signal, Executor executor, ProgressListener listener)
throws ErrnoException {
。。。
while ((t = Os.sendfile(out, in, null, Math.min(count, COPY_CHECKPOINT_BYTES))) != 0) {
progress += t;
checkpoint += t;
count -= t;
。。。
}
}
static jlong Linux_splice(JNIEnv* env, jobject, jobject javaFdIn, jobject javaOffIn, jobject javaFdOut, jobject javaOffOut, jlong len, jint flags) {
。。。
ret = splice(fdIn, (javaOffIn == NULL ? NULL : &offIn),
fdOut, (javaOffOut == NULL ? NULL : &offOut),
len, flags);
。。。
}
copyInternalSplice
copyInternalSplice实际上就是采用了零拷贝技术之一,splice系统调用。这个函数要求是输入输出至少有一个是FIFO,本场景不满足,所有不会进入这个分支。
/**
* Requires one of input or output to be a pipe.
*
* @hide
*/
@VisibleForTesting
public static long copyInternalSplice(FileDescriptor in, FileDescriptor out, long count,
CancellationSignal signal, Executor executor, ProgressListener listener)
throws ErrnoException {
。。。
while ((t = Os.splice(in, null, out, null, Math.min(count, COPY_CHECKPOINT_BYTES),
SPLICE_F_MOVE | SPLICE_F_MORE)) != 0) {
progress += t;
checkpoint += t;
count -= t;
。。。
}
}
static jlong Linux_splice(JNIEnv* env, jobject, jobject javaFdIn, jobject javaOffIn, jobject javaFdOut, jobject javaOffOut, jlong len, jint flags) {
。。。
ret = splice(fdIn, (javaOffIn == NULL ? NULL : &offIn),
fdOut, (javaOffOut == NULL ? NULL : &offOut),
len, flags);
。。。
}
copyInternalUserspace
copyInternalUserspace实际上就是采用传统的文件读写方式来实现拷贝的,即从一个FD读出,然后写入另一个FD,直接在Java层就完成了。这是适用性最广的方式,当然性能也是最差的。不满足任何优化限制条件的场景最后都会执行这个分支。
/** {@hide} */
@VisibleForTesting
public static long copyInternalUserspace(InputStream in, OutputStream out,
CancellationSignal signal, Executor executor, ProgressListener listener)
throws IOException {
。。。
while ((t = in.read(buffer)) != -1) {
out.write(buffer, 0, t);
progress += t;
。。。
}
}
优化方案
Google的原生代码里面,针对adb install这样,输入FD是Socket,输出是普通文件的场景不满足任何优化条件,只能执行普通的拷贝流程。那么针对这种场景,是否可以优化呢?答案是肯定的,可以利用splice来实现,因为splice要求输入,输出FD至少有一个是FIFO。这样我们可以用一个FIFO来作为中介,实现从Socket-->FIFO-->File,这样就利用2次零拷贝的系统调用来完成一次实际拷贝,达到了优化的效果。
下面就是实现Socket-->FIFO-->File的整个函数
static jlong Linux_spliceSocket(JNIEnv* env, jobject, jobject javaFdIn, jobject javaOffIn, jobject javaFdOut, jobject javaOffOut, jlong len, jint flags) {
int fdIn = jniGetFDFromFileDescriptor(env, javaFdIn);
int fdOut = jniGetFDFromFileDescriptor(env, javaFdOut);
int spliceErrno;
int pfd[2];
jlong offIn = (javaOffIn == NULL ? 0 : env->GetLongField(javaOffIn, int64RefValueFid));
jlong offOut = (javaOffOut == NULL ? 0 : env->GetLongField(javaOffOut, int64RefValueFid));
jlong ret = -1;
long count = len;
long sum = 0;
if (count == 0)
return 0;
// 创建一个Pipe用来作为传输中介
pipe(pfd);
do {
bool wasSignaled = false;
{
AsynchronousCloseMonitor monitorIn(fdIn);
AsynchronousCloseMonitor monitorOut(fdOut);
// 从Socket拷贝到Pipe
ret = splice(fdIn, (javaOffIn == NULL ? NULL : &offIn), pfd[1], NULL, count, flags);
if (ret < 0) {
ALOGE("Splice in error, err:%d, errstr:%s\n", errno, strerror(errno));
break;
}
// 从Pipe拷贝到File
ret = splice(pfd[0], NULL, fdOut, (javaOffOut == NULL ? NULL : &offOut), count, flags);
if (ret < 0) {
ALOGE("Splice out error, err:%d, errstr:%s\n", errno, strerror(errno));
break;
}
count -= ret;
sum += ret;
if (count <= 0) {
break;
}
spliceErrno = errno;
wasSignaled = monitorIn.wasSignaled() || monitorOut.wasSignaled();
}
if (wasSignaled) {
jniThrowException(env, "java/io/InterruptedIOException", "splice interrupted");
ret = -1;
break;
}
if (ret == -1 && spliceErrno != EINTR) {
throwErrnoException(env, "splice");
break;
}
} while ((ret == -1) || (count > 0));
if (ret == -1) {
/* If the syscall failed, re-set errno: throwing an exception might have modified it. */
errno = spliceErrno;
} else {
if (javaOffIn != NULL) {
env->SetLongField(javaOffIn, int64RefValueFid, offIn);
}
if (javaOffOut != NULL) {
env->SetLongField(javaOffOut, int64RefValueFid, offOut);
}
}
if (ret > 0)
ret = sum;
// 关闭使用的Pipe
close(pfd[0]);
close(pfd[1]);
return ret;
}
对应的在Copy函数里面,增加一个优化拷贝的判定条件,在输入,输出FD中至少有一个是Socket的时候,调用我们实现的Linux_spliceSocket来执行。
public static long copy(@NonNull FileDescriptor in, @NonNull FileDescriptor out, long count,
@Nullable CancellationSignal signal, @Nullable Executor executor,
@Nullable ProgressListener listener) throws IOException {
if (sEnableCopyOptimizations) {
try {
final StructStat st_in = Os.fstat(in);
final StructStat st_out = Os.fstat(out);
if (S_ISREG(st_in.st_mode) && S_ISREG(st_out.st_mode)) {
return copyInternalSendfile(in, out, count, signal, executor, listener);
} else if (S_ISFIFO(st_in.st_mode) || S_ISFIFO(st_out.st_mode)) {
return copyInternalSplice(in, out, count, signal, executor, listener);
} else if (S_ISSOCK(st_in.st_mode) || S_ISSOCK(st_out.st_mode)) {
return copyInternalSpliceSocket(in, out, count, signal, executor, listener);
}
} catch (ErrnoException e) {
throw e.rethrowAsIOException();
}
}
// Worse case fallback to userspace
return copyInternalUserspace(in, out, count, signal, executor, listener);
}
优化效果
运用Splice零拷贝技术,相比传统的读出再写入方式,减少了2次从内核态到用户态的拷贝消耗,在XXX项目平台上实际测试了优化效果,效果还比较明显。单独文件Copy的耗时减少了30%,整个adb install的耗时降低了5%左右。
总结
本文引入Pipe作为中间媒介的拷贝方法,是一个利用零拷贝技术的通用方法,可以应用在不同的场景,adb install这个场景只是一个实际的应用。当然零拷贝技术也有局限性,就是在此过程中不能对数据进行修改,对于有数据修改要求的场景并不适用。