1. 程式人生 > 其它 >如何實現高效能的IO及其原理?

如何實現高效能的IO及其原理?

程式執行在記憶體以及IO的體現

  首先普及一下常識,如圖所示:

            

  1、在整個記憶體空間中,跑著各種各樣的程式,有Java程式、C程式,他們共用一塊記憶體空間。

  2、對於Java程式,JVM會申請一塊堆空間,通過Xmx可以設定,其餘空間是堆外空間,其中每個執行緒有自己的執行緒棧,保證執行緒記憶體隔離,堆空間使用完以後,會觸發Full FC,堆外空間所有程序可共享使用,無限制。

  3、所有系統執行的程式都必須通過作業系統核心進行IO操作,作業系統也是程式,也需要一定的記憶體空間。

一、使用Buffer代替基本IO

  我們寫一個方法,此方法使用了FileWriter進行了檔案的寫操作,我們都知道不呼叫flush()可能會造成資料丟失,那麼為什麼呢,flush操作到底做了些什麼呢?

public void fileIO() throws Exception {

    File file = new File("/Volumes/work/temp/temp.txt");
    if (file.exists()) {
        file.delete();
    }
    file.createNewFile();

    FileInputStream fileInputStream = new FileInputStream(file);
    FileWriter fileWriter = new FileWriter(file);

    fileWriter.write("hello");
    fileWriter.write("world");
    fileWriter.write("\nhello world");

    Thread.sleep(
99999); fileWriter.flush(); fileWriter.close(); }

  我們知道我們在寫資料的時候不管是C還是Java都會有兩個緩衝區,一個是作業系統的緩衝區sys buffer,還有一個是程式的緩衝區program buffer。那麼剛剛的flush操作是把程式的緩衝區內容寫到了系統緩衝區,還是把系統緩衝區的內容刷到了硬碟呢?因此我們在呼叫flush()之前進行了sleep操作,檢查在flush之前,具體的內容並未寫到temp.txt檔案中,當我們睡眠時間結束後,可以看到呼叫flush方法後則把內容寫到了檔案中,如圖:

          

  實際上FileWriter基本IO是沒有先寫程式快取的,那麼實際上FileWriter的每次write操作都發生了系統呼叫,直接寫到了核心的系統緩衝區,然後當呼叫flush操作時,系統緩衝區的內容再刷到了硬碟上。

  因此IO效能提升第一步:無論是InputStream還是FileWriter,都是底層的IO,是直接呼叫核心的,因此寫入都是直接寫入到核心的系統buffer,因此在使用IO的時候不要使用這類底層IO,否則發生大量系統呼叫,降低系統性能,而是應該先寫到程式buffer然後再呼叫系統IO,當程式buffer滿了後才通過系統呼叫寫到系統buffer空間中,這樣減少了大量系統呼叫,提升了效能。

  那麼什麼時候系統buffer中的資料才寫入到硬碟呢?2種情況:①.系統buffer滿了;②.執行了flush()操作,也就是發生了fsync的系統呼叫。

public void bufferedIO() throws Exception {
    BufferedOutputStream bufferedOutputStream = new BufferedOutputStream(new FileOutputStream(file), 1024);
    BufferedReader reader = new BufferedReader(new FileReader(file));
    bufferedOutputStream.write("hello world\nhello world".getBytes());
    bufferedOutputStream.flush();
    bufferedOutputStream.close();
    String line = reader.readLine();
    System.out.println(line);
}

  還有另一種是直接寫入到記憶體的,如程式碼:

public void memoryIO() throws Exception {
    ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(1024);
    // 位元組陣列輸出流在記憶體中建立一個位元組陣列緩衝區,所有傳送到輸出流的資料儲存在該位元組陣列緩衝區中。可以通過toString()和toByteArray()獲取資料
    byteArrayOutputStream.write("hello world".getBytes());
    String string = byteArrayOutputStream.toString();
    System.out.println(string);

    byte[] inData = byteArrayOutputStream.toByteArray();
    ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(inData);
    byte[] data = new byte[1024];
    byteArrayInputStream.read(data);
    System.out.println(new String(data));

    byteArrayOutputStream.flush();
    byteArrayOutputStream.close();
}

  這樣就類似於Redis一樣,是對記憶體進行直接操作,因此這樣也能提高不少效率。

二、堆外記憶體mmap直接對映核心空間

  如下圖:

            

  1、如果資料在堆內,那麼在寫入磁碟時,會先序列化後拷貝到堆外,然後堆外再write到系統核心緩衝區,核心緩衝區通過系統呼叫fsync寫入到磁碟;

  2、如果資料是在堆外記憶體,那麼也需要先拷貝到核心緩衝區,在fsync系統呼叫後也才寫入到磁碟;

  3、通過系統呼叫mmap申請一塊虛擬的地址空間,這片空間使用者程式和系統核心都可以訪問到。

  如下程式碼:

public void randomIO() throws Exception{
    RandomAccessFile randomAccessFile = new RandomAccessFile(file,"rw");
    randomAccessFile.write("hello world\nhello chicago\nhello ChengDu".getBytes());
    FileChannel channel = randomAccessFile.getChannel();

    /**
     * 堆外的資料如果想寫磁碟,通過系統呼叫,經歷資料從使用者空間拷貝到核心空間
     * 堆外mapedBuffer的資料核心直接處理
     */
    // 分配在了堆上  heap空間
    // ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
    // 分配在了堆外  offheap空間
    // ByteBuffer byteBuffer = ByteBuffer.allocateDirect(1024);
    //mmap  核心系統呼叫  堆外空間,直接對映
    MappedByteBuffer byteBuffer = channel.map(FileChannel.MapMode.READ_WRITE,0,2018);
    
    byteBuffer.put("byteBuffer testing".getBytes());

    randomAccessFile.seek(12);
    randomAccessFile.write("*****".getBytes());
}

  可以看到通過FileChannel的map方法實現系統呼叫,申請mmap直接對映空間,資料無需由使用者空間拷貝到系統空間,節省了一次拷貝的時間損耗,提升了效能。

三、sendfile零拷貝

  在Linux系統中。儲存在檔案中的資訊通過網路傳送給客戶這樣的簡單過程中,所涉及的操作。下面是其中的部分簡單程式碼:

read(file, tmp_buf, len);
write(socket, tmp_buf, len);

  其實過程中實現了多次拷貝,效能很低,如圖可知:

     

  步驟一:系統呼叫read導致了從使用者空間到核心空間的上下文切換。DMA模組從磁碟中讀取檔案內容,並將其儲存在核心空間的緩衝區內,完成了第1次複製。

  步驟二:資料從核心空間緩衝區複製到使用者空間緩衝區,完成了第2次複製,之後系統呼叫read返回,這導致了從核心空間向用戶空間的上下文切換。此時,需要的資料已存放在指定的使用者空間緩衝區內(引數tmp_buf),程式可以繼續下面的操作。

  步驟三:系統呼叫write導致從使用者空間到核心空間的上下文切換。資料從使用者空間緩衝區被再次複製到核心空間緩衝區,完成了第3次複製。不過,這次資料存放在核心空間中與使用的socket相關的特定緩衝區中,而不是步驟一中的緩衝區。

  步驟四:系統呼叫返回,導致了第4次上下文切換。第4次複製在DMA模組將資料從核心空間緩衝區傳遞至協議引擎的時候發生,這與我們的程式碼的執行是獨立且非同步發生的。你可能會疑惑:“為何要說是獨立、非同步?難道不是在write系統呼叫返回前資料已經被傳送了?write系統呼叫的返回,並不意味著傳輸成功——它甚至無法保證傳輸的開始。呼叫的返回,只是表明乙太網驅動程式在其傳輸佇列中有空位,並已經接受我們的資料用於傳輸。可能有眾多的資料排在我們的資料之前。除非驅動程式或硬體採用優先順序佇列的方法,各組資料是依照FIFO的次序被傳輸的(圖1中叉狀的DMA copy表明這最後一次複製可以被延後)。

  因此就誕生了零拷貝:

sendfile(socket, file, len);

  如圖:

    

  步驟一:sendfile系統呼叫導致檔案內容通過DMA模組被複制到核心緩衝區中。

  步驟二:記錄資料位置和長度的描述符被加入到socket緩衝區中,DMA模組將資料直接從核心緩衝區傳遞給協議引擎。

  基於以上實現,最終實現了“零拷貝”。

高效能IO應用

  在現實應用中,Kafka常用來進行日誌處理,存在著大量的IO,其高效能就是建立在IO上的優化,如圖: