暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

Java 零拷贝底层原理之SendFile底层原理

马士兵 2021-07-26
406

Java 层面零拷贝原理

FileChannel零拷贝支撑之transferTo方法

transferTo 方法定义

我们知道FileChannel通道对象的transferTo方法可以用于将文件直接传输给target变量所指的可写通道中。
通常我们可以使用此方法完成对sendfile函数的调用,当然这个调用是通过JNI 来调用的。
我们可以将该方法与SocketChannel一起使用来避免数据先从磁盘传输到用户空间,然后再写回内核,最后放入socket的缓冲区增加性能。
我们先来看该方法的定义,参数position用于表示当前FileChannel操作文件的位置,count为需要传输到target中的数量,target为目标通道对象。
详细实现如下:
    public abstract class FileChannel
    extends AbstractInterruptibleChannel
    implements SeekableByteChannel, GatheringByteChannel, ScatteringByteChannel
    {
    public abstract long transferTo(long position, long count,
    WritableByteChannel target)
    throws IOException;
    }

    transferTo 方法实现原理
    接下来我们来看FileChannelImpl类对于FileChannel类的transferTo方法实现,这里我们省略了对于参数和通道的校验,我们直接关注核心方法。
    我们看到有三种传输方式,第一种需要操作系统接口支持,通过操作系统直接传送数据,第二种通过mmap的方式共享内存传送数据,第三种最慢,为通用的传统方式进行传输,这三种方式逐个进行尝试。
    详细实现如下:
      public class FileChannelImpl extends FileChannel{
      public long transferTo(long position, long count,
      WritableByteChannel target)
      throws IOException
      {
      ... // 参数校验
      long n;
      // 尝试直接传输,需要内核支持
      if ((n = transferToDirectly(position, icount, target)) >= 0)
      return n;
      // 尝试通过mmap共享内存的方式进行可信通道的传输
      if ((n = transferToTrustedChannel(position, icount, target)) >= 0)
      return n;
      // 否则通过传统方式进行传输,这种方式最慢
      return transferToArbitraryChannel(position, icount, target);
      }
      }

      transferToDirectly 方法实现原理

      接下来我们来看transferToDirectly 直接通过操作系统传送的原理,这种方式也是最快的零拷贝支持。
      注意这里的零拷贝指的是需要传送的文件内容不需要从操作系统拷贝到用户空间,然后再写入到socket缓冲区中。
      其中我们省略了校验操作,同样我们关注核心操作,首先我们获取到了当前文件通道和目标通道对象的fd,然后根据是否对position上锁调用transferToDirectlyInternal方法完成数据传送。
      详细实现如下:
        private long transferToDirectly(long position, int icount,WritableByteChannel target) throws IOException {
        ...
        // 获取当前文件通道和目标通道对象的fd
        int thisFDVal = IOUtil.fdVal(fd);
        int targetFDVal = IOUtil.fdVal(targetFD);
        if (thisFDVal == targetFDVal) // 不允许自己传送给自己
        return IOStatus.UNSUPPORTED;
        // 如果需要使用position锁,那么获取该锁调用transferToDirectlyInternal方法完成数据传送,通常transferToDirectlyNeedsPositionLock方法始终返回true
        if (nd.transferToDirectlyNeedsPositionLock()) {
        synchronized (positionLock) {
        long pos = position();
        try {
        return transferToDirectlyInternal(position, icount,
        target, targetFD);
        } finally {
        position(pos);
        }
        }
        } else {
        return transferToDirectlyInternal(position, icount, target, targetFD);
        }
        }

        我们接着看transferToDirectlyInternal方法的实现,我们看到这里我们看到最终通过JNI调用本地方法transferTo0完成传送。

        详细实现如下:

          private long transferToDirectlyInternal(long position, int icount, WritableByteChannel target,FileDescriptor targetFD) throws IOException{
          ...
          do {
          // JNI调用本地方法transferTo0完成传送
          n = transferTo0(fd, position, icount, targetFD);
          } while ((n == IOStatus.INTERRUPTED) && isOpen());
          ...
          }


          // 如果操作系统不支持,那么将会返回-2
          private native long transferTo0(FileDescriptor src, long position,
          long count, FileDescriptor dst);


          transferToTrustedChannel方法实现原理

          transferToTrustedChannel方法用于在可信通道中,使用mmap操作通过共享内存的方式进行数据传送。
          我们看到设置了最大mmap的大小为MAPPED_TRANSFER_SIZE 8M,如果需要传送的文件数据大于这个值,那么我们需要分阶段映射,获取到MappedByteBuffer文件映射缓冲区后,我们调用目标通道的write方法完成数据写入。
          注意我们这里使用的是映射缓冲区,所以此时并不存在两次拷贝,此时只存在数据放入页缓存中,通过mmap映射到进程的虚拟地址空间,而write函数将会直接使用映射的数据,所以不存在拷贝到JVM的堆内存空间,随后再拷贝到内核。
          详细实现如下:
            private long transferToTrustedChannel(long position, long count,WritableByteChannel target)throws IOException{
            ...
            long remaining = count;
            // 我们这里设置了最大mmap的大小为MAPPED_TRANSFER_SIZE 8M,如果需要传送的文件数据大于这个值,那么我们需要分阶段映射
            while (remaining > 0L) {
            long size = Math.min(remaining, MAPPED_TRANSFER_SIZE);
            try {
            // 获取当前文件映射缓冲区
            MappedByteBuffer dbb = map(MapMode.READ_ONLY, position, size);
            try {
            // 调用目标通道进行数据写入
            int n = target.write(dbb);
            ...
            } finally {
            // 写入完成,结束内存映射
            unmap(dbb);
            }
            } catch (ClosedByInterruptException e) {
            ...
            } catch (IOException ioe) {
            ...
            }
            }
            return count - remaining;
            }
            我们接着看map方法原理,我们这里直接调用mapInternal方法进行映射。
            详细实现如下:
              public MappedByteBuffer map(MapMode mode, long position, long size) throws IOException {
              ...
              // 映射并且返回解除映射对象
              Unmapper unmapper = mapInternal(mode, position, size, prot, isSync);
              // 根据模式构建只读MappedByteBufferR或者读写MappedByteBuffer
              if (unmapper == null) {
              FileDescriptor dummy = new FileDescriptor();
              if ((!writable) || (prot == MAP_RO))
              return Util.newMappedByteBufferR(0, 0, dummy, null, isSync);
              else
              return Util.newMappedByteBuffer(0, 0, dummy, null, isSync);
              } else if ((!writable) || (prot == MAP_RO)) {
              return Util.newMappedByteBufferR((int)unmapper.cap,
              unmapper.address + unmapper.pagePosition,
              unmapper.fd,
              unmapper, isSync);
              } else {
              return Util.newMappedByteBuffer((int)unmapper.cap,
              unmapper.address + unmapper.pagePosition,
              unmapper.fd,
              unmapper, isSync);
              }
              }
              我们继续看mapInternal方法,该方法通过JNI调用本地方法完成映射,同时创建了解除映射对象,这里我们使用DefaultUnmapper,详细实现如下。
                private Unmapper mapInternal(MapMode mode, long position, long size, int prot, boolean isSync)
                throws IOException
                {
                ...
                long addr = -1;
                int ti = -1;
                try {
                ...
                synchronized (positionLock) {
                ...
                try {
                // JNI调用本地方法完成映射
                addr = map0(prot, mapPosition, mapSize, isSync);
                } catch (OutOfMemoryError x) {
                ...
                }
                }
                ...
                // 构建解除映射对象,这里我们使用DefaultUnmapper
                Unmapper um = (isSync
                ? new SyncUnmapper(addr, mapSize, size, mfd, pagePosition)
                : new DefaultUnmapper(addr, mapSize, size, mfd, pagePosition));
                return um;
                } finally {
                ...
                }
                }


                private native long map0(int prot, long position, long length, boolean isSync)
                throws IOException;


                transferToArbitraryChannel方法实现原理

                transferToArbitraryChannel方法用于完成最慢的传统传输方式,我们首先创建默认为TRANSFER_SIZE 8KB的堆内缓冲区,随后调用read函数将文件数据读入到该缓冲区中,接着调用目标target.write方法完成对数据的传送。
                我们看到这里发生了多次拷贝操作,磁盘文件到页缓存,页缓存到JVM内存,JVM内存到堆内内存多次拷贝,所以性能最差。
                详细实现如下:
                  private long transferToArbitraryChannel(long position, int icount, WritableByteChannel target)throws IOException{
                  int c = Math.min(icount, TRANSFER_SIZE);
                  // 创建堆内缓冲区
                  ByteBuffer bb = ByteBuffer.allocate(c);
                  long tw = 0; // 总写入数据
                  long pos = position;
                  try {
                  // 循环写入
                  while (tw < icount) {
                  // 将文件数据读入到堆内缓冲区中
                  bb.limit(Math.min((int)(icount - tw), TRANSFER_SIZE));
                  int nr = read(bb, pos);
                  if (nr <= 0)
                  break;
                  // 反转模式,从读模式切换到写模式
                  bb.flip();
                  // 将文件数据写入到堆内缓冲区中
                  int nw = target.write(bb);
                  tw += nw;
                  if (nw != nr)
                  break;
                  pos += nw;
                  // 清空缓冲区,方便下一次读取
                  bb.clear();
                  }
                  return tw;
                  } catch (IOException x) {
                  ...
                  }
                  }


                  JVM 层面零拷贝原理

                  我们在上面看到两个核心本地方法:transferTo0、map0,前者用于在操作系统上直接传输文件到target缓冲区中,map0用于进行数据映射。
                  本节就详细说明这两个函数在JNI层面上的实现原理。


                  JNI原理之transferTo0方法原理

                  我们这里直接看Linux的实现,该方法用C的宏定义来选择不同平台下的函数实现,我们这里只考虑Linux内核的实现,我们看到这里直接调用Linux内核的sendfile64函数进行实现。详细如下。
                    JNIEXPORT jlong JNICALL Java_sun_nio_ch_FileChannelImpl_transferTo0(JNIEnv *env, jobject this,
                    jint srcFD,
                    jlong position, jlong count,
                    jint dstFD)
                    {
                    off64_t offset = (off64_t)position;
                    jlong n = sendfile64(dstFD, // 目标描述符
                    srcFD, // 源描述符
                    &offset, // 源传送偏移量
                    (size_t)count); // 传送大小
                    ...
                    return n;
                    }



                    JNI原理之map0方法原理

                    我们看到该函数首先获取映射文件的fd,随后设置映射的保护权限protections和映射属性flags,然后调用内核的mmap64函数进行映射。
                    详细实现如下:
                      JNIEXPORT jlong JNICALL Java_sun_nio_ch_FileChannelImpl_map0(JNIEnv *env, jobject this,
                      jint prot, jlong off, jlong len){
                      void *mapAddress = 0;

                      jobject fdo = (*env)->GetObjectField(env, this, chan_fd);
                      jint fd = fdval(env, fdo);
                      int protections = 0;
                      int flags = 0;
                      // 设置映射标志位
                      if (prot == sun_nio_ch_FileChannelImpl_MAP_RO) {
                      // 只读映射
                      protections = PROT_READ;
                      flags = MAP_SHARED;
                      } else if (prot == sun_nio_ch_FileChannelImpl_MAP_RW) {
                      // 读写映射
                      protections = PROT_WRITE | PROT_READ;
                      flags = MAP_SHARED;
                      } else if (prot == sun_nio_ch_FileChannelImpl_MAP_PV) {
                      // 私有映射
                      protections = PROT_WRITE | PROT_READ;
                      flags = MAP_PRIVATE;
                      }
                      // 使用Linux mmap64函数进行映射
                      mapAddress = mmap64(
                      0, // 传入期望映射地址为0,表明让内核决定映射起始虚拟地址
                      len, // 映射的长度
                      protections, // 映射地址权限:读、读写
                      flags, // 是否为私有映射
                      fd, // 映射的文件描述符
                      off); // 映射的文件数据偏移量


                      ...
                      return ((jlong) (unsigned long) mapAddress);
                      }

                      Linux内核层面零拷贝原理

                      我们在JNI层面看到了调用了两个核心方法:sendfile64、mmap64,前者用于操作系统直接将数据写入到dstFD文件描述符指定位置,后者用于建立文件数据到JVM进程虚拟地址的映射。
                      我们这一节来看看在Linux层面他们的原理:

                      Linux内核原理之sendfile64方法原理

                      我们先来看sys_sendfile64函数的实现原理,该函数为JNI调用的系统调用函数,首先判断我们是否指定了offset文件写入时的偏移量,如果使用了该偏移量,那么需要将该指针的所指地址的偏移量值移动到内核内存中,随后调用do_sendfile函数完成数据传送。
                      详细实现如下:
                        asmlinkage ssize_t sys_sendfile64(int out_fd, int in_fd, loff_t __user *offset, size_t count)
                        {
                        loff_t pos;
                        ssize_t ret;
                        // 如果指定了偏移量,那么我们这里需要将用户空间传递的数据传入到pos中然后调用do_sendfile完成传送
                        if (offset) {
                        if (unlikely(copy_from_user(&pos, offset, sizeof(loff_t))))
                        return -EFAULT;
                        ret = do_sendfile(out_fd, in_fd, &pos, count, 0);
                        // 将最新的pos放入到offset地址中
                        if (unlikely(put_user(pos, offset)))
                        return -EFAULT;
                        return ret;
                        }
                        return do_sendfile(out_fd, in_fd, NULL, count, 0);
                        }

                        我们继续跟进do_sendfile函数,该函数首先获取到输入、输出的文件对象in_file、out_file和innode对象in_inode、out_inode,在进行参数校验后调用输入文件对象in_file的操作函数f_op结构体的sendfile函数完成数据写出。

                        详细实现如下:

                          static ssize_t do_sendfile(int out_fd, int in_fd, loff_t *ppos,
                          size_t count, loff_t max)
                          {
                          ...
                          // 获取输入文件对象
                          in_file = fget_light(in_fd, &fput_needed_in);
                          ...
                          // 获取输入文件对象innode
                          in_inode = in_file->f_dentry->d_inode;
                          ...
                          // 获取输出文件对象
                          out_file = fget_light(out_fd, &fput_needed_out);
                          ...
                          // 获取输出文件对象innode
                          out_inode = out_file->f_dentry->d_inode;
                          ...
                          // 通过输入文件对象的sendfile完成写入,file_send_actor函数地址为内核读入数据放入Page页中回调
                          retval = in_file->f_op->sendfile(in_file, ppos, count, file_send_actor, out_file);
                          ...
                          return retval;
                          }
                          现在我们知道最终调用的是文件对象的操作函数sendfile函数进行数据写入,我们先来看generic_file_sendfile函数的实现原理,该函数为诸多文件系统innode操作函数。
                          我们看到首先创建了文件读取描述结构read_descriptor_t,随后调用方法do_generic_file_read完成实际写入,写入的数量保存在written变量中。
                          详细实现如下:
                            ssize_t generic_file_sendfile(struct file *in_file, loff_t *ppos,
                            size_t count, read_actor_t actor, void __user *target)
                            {
                            // 创建文件读取描述结构
                            read_descriptor_t desc;
                            if (!count)
                            return 0;
                            desc.written = 0;
                            desc.count = count;
                            desc.buf = target;
                            desc.error = 0;
                            // 完成具体数据传送操作
                            do_generic_file_read(in_file, ppos, &desc, actor);
                            // 返回写入target的数量
                            if (desc.written)
                            return desc.written;
                            return desc.error;
                            }
                            我们接着看do_generic_file_read方法,该方法直接调用do_generic_mapping_read方法来读取磁盘文件数据,然后回调read_actor_t所指向的回调函数。
                            我们看到这里filp->f_dentry->d_inode->i_mapping,在内核中我们通过address_space结构体保存从该inode代表的文件中读取数据存放的page页,&filp->f_ra所指向的file_ra_state用于表示预读数据的状态。
                            详细实现如下:
                              static inline void do_generic_file_read(struct file * filp, loff_t *ppos,
                              read_descriptor_t * desc,
                              read_actor_t actor)
                              {
                              do_generic_mapping_read(filp->f_dentry->d_inode->i_mapping, // 文件数据页信息
                              &filp->f_ra, // 预读状态信息
                              filp, // 当前文件结构
                              ppos, // 读取的文件position地址
                              desc, // 文件读取描述结构
                              actor); // 回调函数
                              }
                              do_generic_mapping_read方法便是完成文件数据读取的核心了,我们看到首先尝试从address_space结构体中获取缓存的物理页帧,address_space结构体维护了一个保存当前inode所指数据的所有页,其中通过基数数来维护这些物理页帧。
                              随后如果我们没有发现该页存在,那么需要进入handle_ra_miss方法记录状态,同时跳转到no_cached_page中分配新的物理页帧,随后跳转到readpage将磁盘中的数据读取到该物理页帧中,然后调用read_actor_t回调函数,该函数用于处理该页数据。
                                void do_generic_mapping_read(struct address_space *mapping,
                                struct file_ra_state *ra,
                                struct file * filp,
                                loff_t *ppos,
                                read_descriptor_t * desc,
                                read_actor_t actor)
                                {
                                // 获取address_space结构所属的inode
                                struct inode *inode = mapping->host;
                                ...
                                for (;;) {
                                ...
                                find_page:
                                // 尝试从address_space地址空间中直接获取物理页帧(为了方便理解原理,我这里没有写index下标的计算)
                                page = find_get_page(mapping, index);
                                // 页为空,那么我们需要调用handle_ra_miss获取一个新的物理页帧,随后跳转到no_cached_page地址处执行
                                if (unlikely(page == NULL)) {
                                handle_ra_miss(mapping, ra, index);
                                goto no_cached_page;
                                }
                                // 验证此时获取的物理页帧中的内容是否有效,如果无效,那么需要调用page_not_up_to_date方法从磁盘中读取数据
                                if (!PageUptodate(page))
                                goto page_not_up_to_date;
                                page_ok:
                                ...
                                // 调用回调函数处理该页
                                ret = actor(desc, page, offset, nr);
                                ...
                                page_not_up_to_date:
                                // 此时物理页帧的数据无效
                                if (PageUptodate(page))
                                goto page_ok;
                                ...
                                readpage:
                                // 从磁盘中读取数据放入该页
                                error = mapping->a_ops->readpage(filp, page);
                                ...
                                no_cached_page:
                                // 此时表明还未缓存该页,那么我们需要获取一个新的物理页帧
                                if (!cached_page) {
                                // 从内存管理系统中分配一个不再CPU 高速缓存中的物理页帧放入address_space中
                                cached_page = page_cache_alloc_cold(mapping);
                                if (!cached_page) {
                                desc->error = -ENOMEM;
                                break;
                                }
                                }
                                // 将其添加到页缓存的lru队列
                                error = add_to_page_cache_lru(cached_page, mapping,
                                index, GFP_KERNEL);
                                ...
                                // 获取到新页后读取磁盘数据放入其中
                                goto readpage;
                                }
                                ...
                                }
                                最后我们来看file_send_actor函数,我们知道该函数在do_generic_mapping_read中将数据放入物理页帧page后回调。
                                我们看到其中将会调用输出文件指针file的sendpage文件操作函数来使用该页,详细实现如下:
                                  int file_send_actor(read_descriptor_t * desc, struct page *page, unsigned long offset, unsigned long size)
                                  {
                                  ...
                                  written = file->f_op->sendpage(file, page, offset,
                                  size, &file->f_pos, size<count);
                                  ...
                                  // 返回实际写入数量
                                  return written;
                                  }

                                  我们这里假设我们使用的输出通道对象为SocketChannel,也即网络端。

                                  那么我们就会进入Socket的sock_sendpage方法中,我们看到该方法获取到了file代表的socket结构,随后调用该结构的sendpage函数。

                                  详细实现如下:

                                    ssize_t sock_sendpage(struct file *file, struct page *page,
                                    int offset, size_t size, loff_t *ppos, int more)
                                    {
                                    ...
                                    // 根据d_inode结构获取socket的地址
                                    sock = SOCKET_I(file->f_dentry->d_inode);
                                    ...
                                    // 调用socket的sendpage操作
                                    return sock->ops->sendpage(sock, page, offset, size, flags);
                                    }

                                    由于TCP相较于UDP较为复杂,我们这里只关注页的去向,所以我们以UDP协议为例。

                                    在udp_sendpage中调用ip_append_page将该页放入写缓冲区中,而在ip_append_page中将会调用skb_fill_page_desc(skb, i, page, offset, len)方法,向UDP发送队列添加一个数据报。

                                    同时ip_append_page不会拷贝物理页帧的数据,只是将skb_frag_t结构指向该页帧。

                                      int udp_sendpage(struct sock *sk, struct page *page, int offset, size_t size, int flags)
                                      {
                                      ...
                                      ret = ip_append_page(sk, page, offset, size, flags);
                                      ...
                                      return ret;
                                      }

                                      关于作者:
                                      ——————
                                      进行业交流群
                                      👇推荐关注👇
                                      有趣的行业资讯
                                      干货技术分享
                                      程序员的日常生活
                                      ......
                                      干就完了
                                      文章转载自马士兵,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

                                      评论