歡迎光臨
每天分享高質量文章

你真的理解零複製嗎?


前言

從字面意思理解就是資料不需要來回的複製,大大提升了系統的效能;這個詞我們也經常在java nio,netty,kafka,RocketMQ等框架中聽到,經常作為其提升效能的一大亮點;下麵從I/O的幾個概念開始,進而在分析零複製。

I/O概念

1.緩衝區

緩衝區是所有I/O的基礎,I/O講的無非就是把資料移進或移出緩衝區;行程執行I/O操作,就是向作業系統發出請求,讓它要麼把緩衝區的資料排乾(寫),要麼填充緩衝區(讀);下麵看一個java行程發起read請求載入資料大致的流程圖:

行程發起read請求之後,核心接收到read請求之後,會先檢查核心空間中是否已經存在行程所需要的資料,如果已經存在,則直接把資料copy給行程的緩衝區;如果沒有核心隨即向磁碟控制器發出命令,要求從磁碟讀取資料,磁碟控制器把資料直接寫入核心read緩衝區,這一步透過DMA完成;接下來就是核心將資料copy到行程的緩衝區;如果行程發起write請求,同樣需要把使用者緩衝區裡面的資料copy到內核的socket緩衝區裡面,然後再透過DMA把資料copy到網絡卡中,發送出去;你可能覺得這樣挺浪費空間的,每次都需要把核心空間的資料複製到使用者空間中,所以零複製的出現就是為瞭解決這種問題的;關於零複製提供了兩種方式分別是:mmap+write方式,sendfile方式;

2.虛擬記憶體

所有現代作業系統都使用虛擬記憶體,使用虛擬的地址取代物理地址,這樣做的好處是:1.一個以上的虛擬地址可以指向同一個物理記憶體地址,
2.虛擬記憶體空間可大於實際可用的物理地址;利用第一條特性可以把核心空間地址和使用者空間的虛擬地址對映到同一個物理地址,這樣DMA就可以填充對內核和使用者空間行程同時可見的緩衝區了,大致如下圖所示:

省去了核心與使用者空間的往來複製,java也利用作業系統的此特性來提升效能,下麵重點看看java對零複製都有哪些支援。

3.mmap+write方式

使用mmap+write方式代替原來的read+write方式,mmap是一種記憶體對映檔案的方法,即將一個檔案或者其它物件對映到行程的地址空間,實現檔案磁碟地址和行程虛擬地址空間中一段虛擬地址的一一對映關係;這樣就可以省掉原來核心read緩衝區copy資料到使用者緩衝區,但是還是需要核心read緩衝區將資料copy到核心socket緩衝區,大致如下圖所示:

4.sendfile方式

sendfile系統呼叫在核心版本2.1中被引入,目的是簡化透過網路在兩個通道之間進行的資料傳輸過程。sendfile系統呼叫的引入,不僅減少了資料複製,還減少了背景關係切換的次數,大致如下圖所示:

資料傳送只發生在核心空間,所以減少了一次背景關係切換;但是還是存在一次copy,能不能把這一次copy也省略掉,Linux2.4核心中做了改進,將Kernel buffer中對應的資料描述資訊(記憶體地址,偏移量)記錄到相應的socket緩衝區當中,這樣連核心空間中的一次cpu copy也省掉了;

Java零複製

1.MappedByteBuffer

java nio提供的FileChannel提供了map()方法,該方法可以在一個開啟的檔案和MappedByteBuffer之間建立一個虛擬記憶體對映,MappedByteBuffer繼承於ByteBuffer,類似於一個基於記憶體的緩衝區,只不過該物件的資料元素儲存在磁碟的一個檔案中;呼叫get()方法會從磁碟中獲取資料,此資料反映該檔案當前的內容,呼叫put()方法會更新磁碟上的檔案,並且對檔案做的修改對其他閱讀者也是可見的;下麵看一個簡單的讀取實體,然後在對MappedByteBuffer進行分析:

  1. public class MappedByteBufferTest {
  2.  
  3. public static void main(String[] args) throws Exception {
  4. File file = new File("D://db.txt");
  5. long len = file.length();
  6. byte[] ds = new byte[(int) len];
  7. MappedByteBuffer mappedByteBuffer = new FileInputStream(file).getChannel().map(FileChannel.MapMode.READ_ONLY, 0,
  8. len);
  9. for (int offset = 0; offset < len; offset++) {
  10. byte b = mappedByteBuffer.get();
  11. ds[offset] = b;
  12. }
  13. Scanner scan = new Scanner(new ByteArrayInputStream(ds)).useDelimiter(" ");
  14. while (scan.hasNext()) {
  15. System.out.print(scan.next() + " ");
  16. }
  17. }
  18. }

主要透過FileChannel提供的map()來實現對映,map()方法如下:

  1. public abstract MappedByteBuffer map(MapMode mode,
  2. long position, long size)
  3. throws IOException;

分別提供了三個引數,MapMode,Position和size;分別表示:

  • MapMode:對映的樣式,可選項包括:READONLY,READWRITE,PRIVATE;
  • Position:從哪個位置開始對映,位元組數的位置;
  • Size:從position開始向後多少個位元組;

重點看一下MapMode,請兩個分別表示只讀和可讀可寫,當然請求的對映樣式受到Filechannel物件的訪問許可權限制,如果在一個沒有讀許可權的檔案上啟用READ_ONLY,將丟擲NonReadableChannelException;PRIVATE樣式表示寫時複製的對映,意味著透過put()方法所做的任何修改都會導致產生一個私有的資料複製並且該複製中的資料只有MappedByteBuffer實體可以看到;該過程不會對底層檔案做任何修改,而且一旦緩衝區被施以垃圾收集動作(garbage collected),那些修改都會丟失;大致瀏覽一下map()方法的原始碼:

  1. public MappedByteBuffer map(MapMode mode, long position, long size)
  2. throws IOException
  3. {
  4. ...省略...
  5. int pagePosition = (int)(position % allocationGranularity);
  6. long mapPosition = position - pagePosition;
  7. long mapSize = size + pagePosition;
  8. try {
  9. // If no exception was thrown from map0, the address is valid
  10. addr = map0(imode, mapPosition, mapSize);
  11. } catch (OutOfMemoryError x) {
  12. // An OutOfMemoryError may indicate that we've exhausted memory
  13. // so force gc and re-attempt map
  14. System.gc();
  15. try {
  16. Thread.sleep(100);
  17. } catch (InterruptedException y) {
  18. Thread.currentThread().interrupt();
  19. }
  20. try {
  21. addr = map0(imode, mapPosition, mapSize);
  22. } catch (OutOfMemoryError y) {
  23. // After a second OOME, fail
  24. throw new IOException("Map failed", y);
  25. }
  26. }
  27.  
  28. // On Windows, and potentially other platforms, we need an open
  29. // file descriptor for some mapping operations.
  30. FileDescriptor mfd;
  31. try {
  32. mfd = nd.duplicateForMapping(fd);
  33. } catch (IOException ioe) {
  34. unmap0(addr, mapSize);
  35. throw ioe;
  36. }
  37.  
  38. assert (IOStatus.checkAll(addr));
  39. assert (addr % allocationGranularity == 0);
  40. int isize = (int)size;
  41. Unmapper um = new Unmapper(addr, mapSize, isize, mfd);
  42. if ((!writable) || (imode == MAP_RO)) {
  43. return Util.newMappedByteBufferR(isize,
  44. addr + pagePosition,
  45. mfd,
  46. um);
  47. } else {
  48. return Util.newMappedByteBuffer(isize,
  49. addr + pagePosition,
  50. mfd,
  51. um);
  52. }
  53. }

大致意思就是透過native方法獲取記憶體對映的地址,如果失敗,手動gc再次對映;最後透過記憶體對映的地址實體化出MappedByteBuffer,MappedByteBuffer本身是一個抽象類,其實這裡真正實體話出來的是DirectByteBuffer;

2.DirectByteBuffer

DirectByteBuffer繼承於MappedByteBuffer,從名字就可以猜測出開闢了一段直接的記憶體,並不會佔用jvm的記憶體空間;上一節中透過Filechannel映射出的MappedByteBuffer其實際也是DirectByteBuffer,當然除了這種方式,也可以手動開闢一段空間:

  1. ByteBuffer directByteBuffer = ByteBuffer.allocateDirect(100);

如上開闢了100位元組的直接記憶體空間;

3.Channel-to-Channel傳輸

經常需要從一個位置將檔案傳輸到另外一個位置,FileChannel提供了transferTo()方法用來提高傳輸的效率,首先看一個簡單的實體:

  1. public class ChannelTransfer {
  2. public static void main(String[] argv) throws Exception {
  3. String files[]=new String[1];
  4. files[0]="D://db.txt";
  5. catFiles(Channels.newChannel(System.out), files);
  6. }
  7.  
  8. private static void catFiles(WritableByteChannel target, String[] files)
  9. throws Exception {
  10. for (int i = 0; i < files.length; i++) {
  11. FileInputStream fis = new FileInputStream(files[i]);
  12. FileChannel channel = fis.getChannel();
  13. channel.transferTo(0, channel.size(), target);
  14. channel.close();
  15. fis.close();
  16. }
  17. }
  18. }

透過FileChannel的transferTo()方法將檔案資料傳輸到System.out通道,介面定義如下:

  1. public abstract long transferTo(long position, long count,
  2. WritableByteChannel target)
  3. throws IOException;

幾個引數也比較好理解,分別是開始傳輸的位置,傳輸的位元組數,以及標的通道;transferTo()允許將一個通道交叉連線到另一個通道,而不需要一個中間緩衝區來傳遞資料;註:這裡不需要中間緩衝區有兩層意思:第一層不需要使用者空間緩衝區來複製核心緩衝區,另外一層兩個通道都有自己的核心緩衝區,兩個核心緩衝區也可以做到無需複製資料;

Netty零複製

netty提供了零複製的buffer,在傳輸資料時,最終處理的資料會需要對單個傳輸的報文,進行組合和拆分,Nio原生的ByteBuffer無法做到,netty透過提供的Composite(組合)和Slice(拆分)兩種buffer來實現零複製;看下麵一張圖會比較清晰:

TCP層HTTP報文被分成了兩個ChannelBuffer,這兩個Buffer對我們上層的邏輯(HTTP處理)是沒有意義的。但是兩個ChannelBuffer被組合起來,就成為了一個有意義的HTTP報文,這個報文對應的ChannelBuffer,才是能稱之為”Message”的東西,這裡用到了一個詞”Virtual Buffer”。

可以看一下netty提供的CompositeChannelBuffer原始碼:

  1. public class CompositeChannelBuffer extends AbstractChannelBuffer {
  2.  
  3. private final ByteOrder order;
  4. private ChannelBuffer[] components;
  5. private int[] indices;
  6. private int lastAccessedComponentId;
  7. private final boolean gathering;
  8.  
  9. public byte getByte(int index) {
  10. int componentId = componentId(index);
  11. return components[componentId].getByte(index - indices[componentId]);
  12. }
  13. ...省略...

components用來儲存的就是所有接收到的buffer,indices記錄每個buffer的起始位置,lastAccessedComponentId記錄上一次訪問的ComponentId;CompositeChannelBuffer並不會開闢新的記憶體並直接複製所有ChannelBuffer內容,而是直接儲存了所有ChannelBuffer的取用,併在子ChannelBuffer裡進行讀寫,實現了零複製。

其他零複製

RocketMQ的訊息採用順序寫到commitlog檔案,然後利用consume queue檔案作為索引;RocketMQ採用零複製mmap+write的方式來回應Consumer的請求;同樣kafka中存在大量的網路資料持久化到磁碟和磁碟檔案透過網路傳送的過程,kafka使用了sendfile零複製方式;

總結

零複製如果簡單用java裡面物件的機率來理解的話,其實就是使用的都是物件的取用,每個取用物件的地方對其改變就都能改變此物件,永遠只存在一份物件。

    已同步到看一看
    贊(0)

    分享創造快樂