用零拷贝技术优化adb install的执行时间

3,172 阅读7分钟

背景

某项目有对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);
    }

看这个函数的代码,可以得到以下信息

  1. copy是一个单纯函数,就是根据传入的输入输出FD,完成文件复制标准操作。

  2. Google根据FD的类型,进行了相应优化操作

    1. 如果输入输出都是普通文件,则调用copyInternalSendfile

    2. 如果输入输出有一个是FIFO文件,则调用copyInternalSplice

    3. 其他情况,则调用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这个场景只是一个实际的应用。当然零拷贝技术也有局限性,就是在此过程中不能对数据进行修改,对于有数据修改要求的场景并不适用。

参考文献

应用程序安装流程

IO 零拷贝 - 掘金