細談 Linux 中的多路複用epoll

威哥爱编程發表於2024-11-05

大家好,我是 V 哥。在 Linux 中,epoll 是一種多路複用機制,用於高效地處理大量檔案描述符(file descriptor, FD)事件。與傳統的selectpoll相比,epoll具有更高的效能和可擴充套件性,特別是在大規模併發場景下,比如高併發伺服器。

以下是epoll的核心資料結構和實現原理:

1. epoll的核心資料結構

在 Linux 核心中,epoll的實現涉及多個核心資料結構,主要包括以下幾個:

(1) epoll例項

epoll在建立時,會生成一個與之關聯的例項,這個例項在核心中是一個epoll檔案物件(struct file),並且與使用者態的epoll檔案描述符(FD)對應。該例項負責維護和管理所有加入的事件。

(2) 事件等待佇列(epitem

epoll中的每個事件都被封裝成一個epitem結構。該結構體主要包括以下幾個關鍵內容:

  • 指向被監聽檔案的指標:用於標識監聽的檔案物件。
  • 事件型別和事件掩碼:指定關注的事件型別(如可讀、可寫、異常等)。
  • 雙向連結串列節點:用於將所有的epitem結構體組織成連結串列(或紅黑樹)。

(3) 紅黑樹(RB-Tree)

為了快速查詢和管理epitemepoll使用紅黑樹將所有的epitem組織起來。每個被監聽的檔案描述符及其事件型別會儲存在紅黑樹中,透過這種方式,可以在事件新增、刪除、修改時實現高效的查詢和管理。

(4) 就緒佇列(Ready List)

當監聽的檔案描述符上發生指定的事件時,epoll會將該檔案描述符的事件加入一個就緒佇列。這個佇列是一個雙向連結串列,儲存所有準備好處理的epitem。當使用者呼叫epoll_wait時,核心從該佇列中取出滿足條件的事件並返回。

2. epoll的三種操作

epoll提供三種主要的操作介面:epoll_createepoll_ctlepoll_wait

(1) epoll_create

epoll_create用於建立一個epoll例項,並返回一個檔案描述符。它會在核心中分配epoll資料結構,並初始化就緒佇列、紅黑樹等結構。它主要完成以下任務:

  • 分配一個epoll例項,並初始化相關的資料結構。
  • 建立一個檔案描述符供使用者引用。

(2) epoll_ctl

epoll_ctl用於將事件新增到epoll例項中,或從epoll例項中移除,或修改現有事件。具體操作包括:

  • 新增事件(EPOLL_CTL_ADD):將新事件新增到epoll中,即將檔案描述符及其事件掩碼包裝成epitem結構體,然後插入紅黑樹。
  • 刪除事件(EPOLL_CTL_DEL):將事件從epoll例項中移除,即從紅黑樹中刪除對應的epitem
  • 修改事件(EPOLL_CTL_MOD):修改現有的事件,比如修改事件掩碼或回撥方式。

透過紅黑樹結構,epoll_ctl操作的新增、刪除、修改事件在平均時間複雜度上為 (O(\log N)),相較於poll的線性複雜度更具效能優勢。

(3) epoll_wait

epoll_wait用於等待檔案描述符上的事件,直到有事件觸發或超時。其主要過程包括:

  • 遍歷就緒佇列,將所有已經準備好的事件放入使用者態緩衝區,並清空佇列。
  • 如果沒有事件發生,核心會讓呼叫執行緒進入休眠狀態,並在監聽的事件發生後喚醒。
  • epoll會利用中斷機制高效地喚醒阻塞在epoll_wait上的執行緒,從而實現事件驅動的處理方式。

epoll_wait只需遍歷就緒佇列中的事件,而不是遍歷所有的監聽事件,這使得效能相較於selectpoll有顯著提升。特別是在大量檔案描述符中僅有少數活躍時,epoll_wait的優勢更為明顯。

3. epoll的觸發模式

epoll提供兩種觸發模式來控制事件的觸發方式:

(1) 水平觸發(LT, Level Triggered)

在預設的水平觸發模式下,只要檔案描述符上有指定的事件(如資料可讀),每次呼叫epoll_wait都會返回此事件,除非事件被處理(如資料被讀走)。這是與pollselect一致的行為。

(2) 邊緣觸發(ET, Edge Triggered)

在邊緣觸發模式下,epoll_wait只會在事件第一次發生時通知,之後即使該事件條件一直滿足(如資料仍可讀),也不會再次觸發,除非事件條件有新的變化。該模式能夠減少不必要的系統呼叫次數,但要求應用程式在接收到通知後必須一次性處理所有資料,否則可能會錯過事件。

4. epoll的優缺點

優點:

  • 高效的事件監聽:使用紅黑樹管理監聽事件,提高了事件的增刪查效率。
  • 事件驅動的高併發處理:透過邊緣觸發模式,減少系統呼叫次數,適合高併發場景。
  • 就緒事件分離:就緒佇列與監聽列表分離,不必遍歷所有檔案描述符,從而大大提升了效能。

缺點:

  • 只支援 Linuxepoll是 Linux 特有的實現,跨平臺相容性較差。
  • 程式設計複雜度:相比selectpollepoll需要更精細的控制,特別是在邊緣觸發模式下應用程式需要處理全部資料,以防止事件丟失。

5. Java NIO 如何使用多路複用

下面 V 哥用案例來詳細說一說Java 中的多路複用。在 Java NIO 中,Selector 類實現了多路複用機制,底層使用 epollpoll 實現。Java NIO 中的多路複用非常適合處理大量併發連線,比如在高併發的伺服器場景中。以下是使用 Java NIO 和 Selector 建立一個簡化的聊天伺服器示例,透過多路複用處理多個客戶端連線。

示例:NIO 實現的聊天伺服器

這個伺服器使用 ServerSocketChannel 來監聽客戶端連線,透過 Selector 監聽和管理事件,並使用 SocketChannel 處理每個連線。客戶端連線後可以傳送訊息,伺服器會將訊息廣播給所有其他連線的客戶端。

import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.*;
import java.util.*;

public class WGNioChatServer {
    private final int port;
    private Selector selector;
    private ServerSocketChannel serverSocketChannel;
    private final Map<SocketChannel, String> clientNames = new HashMap<>(); // 儲存客戶端名稱

    public WGNioChatServer(int port) {
        this.port = port;
    }

    public void start() throws IOException {
        // 初始化伺服器通道和選擇器
        selector = Selector.open();
        serverSocketChannel = ServerSocketChannel.open();
        serverSocketChannel.configureBlocking(false);
        serverSocketChannel.bind(new InetSocketAddress(port));
        serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);

        System.out.println("Chat server started on port " + port);

        while (true) {
            // 輪詢準備就緒的事件
            selector.select();
            Iterator<SelectionKey> keyIterator = selector.selectedKeys().iterator();
            
            while (keyIterator.hasNext()) {
                SelectionKey key = keyIterator.next();
                keyIterator.remove();

                if (key.isAcceptable()) {
                    handleAccept();
                } else if (key.isReadable()) {
                    handleRead(key);
                }
            }
        }
    }

    // 處理新客戶端連線
    private void handleAccept() throws IOException {
        SocketChannel clientChannel = serverSocketChannel.accept();
        clientChannel.configureBlocking(false);
        clientChannel.register(selector, SelectionKey.OP_READ);
        
        String clientAddress = clientChannel.getRemoteAddress().toString();
        clientNames.put(clientChannel, clientAddress);
        System.out.println("Connected: " + clientAddress);
        
        broadcast("User " + clientAddress + " joined the chat", clientChannel);
    }

    // 讀取客戶端訊息並廣播給其他客戶端
    private void handleRead(SelectionKey key) throws IOException {
        SocketChannel clientChannel = (SocketChannel) key.channel();
        ByteBuffer buffer = ByteBuffer.allocate(256);
        int bytesRead = clientChannel.read(buffer);

        if (bytesRead == -1) {
            // 客戶端斷開連線
            String clientName = clientNames.get(clientChannel);
            System.out.println("Disconnected: " + clientName);
            clientNames.remove(clientChannel);
            key.cancel();
            clientChannel.close();
            broadcast("User " + clientName + " left the chat", clientChannel);
            return;
        }

        buffer.flip();
        String message = new String(buffer.array(), 0, bytesRead);
        System.out.println(clientNames.get(clientChannel) + ": " + message.trim());

        broadcast(clientNames.get(clientChannel) + ": " + message, clientChannel);
    }

    // 向所有客戶端廣播訊息
    private void broadcast(String message, SocketChannel sender) throws IOException {
        ByteBuffer buffer = ByteBuffer.wrap(message.getBytes());

        for (SelectionKey key : selector.keys()) {
            Channel targetChannel = key.channel();

            if (targetChannel instanceof SocketChannel && targetChannel != sender) {
                SocketChannel clientChannel = (SocketChannel) targetChannel;
                clientChannel.write(buffer.duplicate());
            }
        }
    }

    public static void main(String[] args) throws IOException {
        int port = 123456;
        new WGNioChatServer(port).start();
    }
}

程式碼說明

  1. 初始化伺服器

    • 使用 ServerSocketChannel.open() 建立伺服器套接字通道,配置為非阻塞模式,並繫結埠。
    • 使用 Selector.open() 建立選擇器並將 ServerSocketChannel 註冊到 Selector 上,監聽連線事件 SelectionKey.OP_ACCEPT
  2. 事件處理

    • selector.select() 會阻塞直到至少一個通道變為就緒狀態。
    • key.isAcceptable():處理新的客戶端連線,將新客戶端通道註冊到選擇器中,監聽讀取事件 SelectionKey.OP_READ
    • key.isReadable():讀取來自客戶端的訊息並廣播給所有其他客戶端。
  3. 廣播機制

    • 使用 Selector.keys() 遍歷所有註冊的通道(包含當前連線的所有客戶端),將訊息寫入除傳送者之外的所有客戶端通道。

業務場景擴充套件

在實際業務中,可以進一步最佳化或擴充套件這個程式碼,比如:

  • 增加心跳檢測來處理空閒客戶端連線,避免資源浪費。
  • 將每個 SocketChannel 放到單獨的執行緒池中處理,以實現更精細的併發控制。
  • 實現訊息格式協議(如 JSON 或 Protobuf)來傳輸結構化資料。

6. 最佳化一下

在實際業務場景中,我們可以基於 Java NIO 對該聊天伺服器進行如下最佳化:

  1. 心跳檢測:定期檢測客戶端連線是否空閒,斷開長時間無響應的連線,以節省資源。
  2. 執行緒池處理:將每個 SocketChannel 的訊息處理放入執行緒池,以避免阻塞主執行緒,提高併發效能。
  3. 訊息協議格式:使用 JSON 格式封裝訊息內容,使客戶端與服務端之間的訊息更加結構化。

下面是最佳化後的程式碼實現:

import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.*;
import java.util.*;
import java.util.concurrent.*;
import com.fasterxml.jackson.databind.ObjectMapper;

public class EnhancedNioChatServer {
    private final int port;
    private Selector selector;  // 多路複用器,負責管理多個通道
    private ServerSocketChannel serverSocketChannel;  // 伺服器通道,用於監聽客戶端連線
    private final Map<SocketChannel, String> clientNames = new HashMap<>();  // 儲存客戶端名稱
    private final Map<SocketChannel, Long> lastActiveTime = new ConcurrentHashMap<>();  // 儲存客戶端最後活動時間
    private final ScheduledExecutorService heartbeatScheduler = Executors.newScheduledThreadPool(1);  // 心跳檢測定時任務
    private final ExecutorService workerPool = Executors.newFixedThreadPool(10);  // 處理客戶端請求的執行緒池
    private final ObjectMapper objectMapper = new ObjectMapper();  // 用於 JSON 序列化的物件

    public EnhancedNioChatServer(int port) {
        this.port = port;
    }

    public void start() throws IOException {
        // 初始化伺服器通道和選擇器
        selector = Selector.open();
        serverSocketChannel = ServerSocketChannel.open();
        serverSocketChannel.configureBlocking(false);  // 配置非阻塞模式
        serverSocketChannel.bind(new InetSocketAddress(port));  // 繫結埠
        serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);  // 註冊連線接收事件

        System.out.println("Chat server started on port " + port);

        // 啟動心跳檢測任務
        startHeartbeatCheck();

        while (true) {
            selector.select();  // 阻塞直到至少有一個事件發生
            Iterator<SelectionKey> keyIterator = selector.selectedKeys().iterator();
            
            while (keyIterator.hasNext()) {
                SelectionKey key = keyIterator.next();
                keyIterator.remove();  // 防止重複處理

                if (key.isAcceptable()) {
                    handleAccept();  // 處理客戶端連線
                } else if (key.isReadable()) {
                    handleRead(key);  // 處理客戶端的訊息讀取
                }
            }
        }
    }

    // 處理新的客戶端連線
    private void handleAccept() throws IOException {
        SocketChannel clientChannel = serverSocketChannel.accept();  // 接受新的客戶端連線
        clientChannel.configureBlocking(false);  // 設定非阻塞模式
        clientChannel.register(selector, SelectionKey.OP_READ);  // 註冊讀事件
        
        String clientAddress = clientChannel.getRemoteAddress().toString();
        clientNames.put(clientChannel, clientAddress);  // 儲存客戶端地址
        lastActiveTime.put(clientChannel, System.currentTimeMillis());  // 記錄最後活動時間
        
        System.out.println("Connected: " + clientAddress);
        broadcast(new Message("System", "User " + clientAddress + " joined the chat"), clientChannel);
    }

    // 處理讀取客戶端訊息
    private void handleRead(SelectionKey key) {
        SocketChannel clientChannel = (SocketChannel) key.channel();
        ByteBuffer buffer = ByteBuffer.allocate(256);  // 緩衝區用於讀取客戶端資料

        // 使用執行緒池處理,以免阻塞主執行緒
        workerPool.submit(() -> {
            try {
                int bytesRead = clientChannel.read(buffer);  // 讀取客戶端資料
                if (bytesRead == -1) {
                    disconnect(clientChannel);  // 客戶端關閉連線
                    return;
                }
                lastActiveTime.put(clientChannel, System.currentTimeMillis());  // 更新最後活動時間
                buffer.flip();  // 準備讀取緩衝區內容

                String messageContent = new String(buffer.array(), 0, bytesRead).trim();
                Message message = new Message(clientNames.get(clientChannel), messageContent);
                System.out.println(message.getSender() + ": " + message.getContent());

                broadcast(message, clientChannel);  // 廣播訊息給其他客戶端
            } catch (IOException e) {
                disconnect(clientChannel);  // 處理異常情況下的客戶端斷開
            }
        });
    }

    // 處理客戶端斷開連線
    private void disconnect(SocketChannel clientChannel) {
        try {
            String clientName = clientNames.get(clientChannel);
            System.out.println("Disconnected: " + clientName);
            clientNames.remove(clientChannel);  // 移除客戶端資訊
            lastActiveTime.remove(clientChannel);  // 移除最後活動時間
            clientChannel.close();  // 關閉連線
            broadcast(new Message("System", "User " + clientName + " left the chat"), clientChannel);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    // 廣播訊息給所有連線的客戶端(除了訊息傳送者)
    private void broadcast(Message message, SocketChannel sender) {
        ByteBuffer buffer;
        try {
            buffer = ByteBuffer.wrap(objectMapper.writeValueAsBytes(message));  // 將訊息序列化為 JSON
        } catch (IOException e) {
            e.printStackTrace();
            return;
        }

        for (SelectionKey key : selector.keys()) {
            Channel targetChannel = key.channel();
            if (targetChannel instanceof SocketChannel && targetChannel != sender) {  // 排除傳送者
                SocketChannel clientChannel = (SocketChannel) targetChannel;
                try {
                    clientChannel.write(buffer.duplicate());  // 寫入訊息
                } catch (IOException e) {
                    disconnect(clientChannel);  // 處理寫入失敗的情況
                }
            }
        }
    }

    // 定期檢查客戶端是否超時未響應,超時則斷開連線
    private void startHeartbeatCheck() {
        heartbeatScheduler.scheduleAtFixedRate(() -> {
            long currentTime = System.currentTimeMillis();
            for (SocketChannel clientChannel : lastActiveTime.keySet()) {
                long lastActive = lastActiveTime.get(clientChannel);
                if (currentTime - lastActive > 60000) {  // 如果超時 1 分鐘
                    System.out.println("Client timeout: " + clientNames.get(clientChannel));
                    disconnect(clientChannel);  // 斷開超時客戶端
                }
            }
        }, 10, 30, TimeUnit.SECONDS);  // 每隔 30 秒執行一次
    }

    public static void main(String[] args) throws IOException {
        int port = 123456;  // 定義埠號
        new EnhancedNioChatServer(port).start();  // 啟動伺服器
    }

    // 用於封裝訊息的內部類
    private static class Message {
        private String sender;
        private String content;

        public Message(String sender, String content) {
            this.sender = sender;
            this.content = content;
        }

        public String getSender() {
            return sender;
        }

        public String getContent() {
            return content;
        }
    }
}

解釋一下

  1. selectorserverSocketChannel:負責管理通道事件和連線。
  2. clientNameslastActiveTime:用於儲存客戶端資訊,確保記錄和維護連線狀態。
  3. heartbeatScheduler:定時執行心跳檢測任務,定期檢查每個客戶端的活動狀態,斷開超時連線。
  4. workerPool:執行緒池用於非同步處理每個客戶端的訊息讀取操作。
  5. 訊息廣播和心跳檢測:使用 JSON 格式訊息封裝,訊息廣播會將訊息傳送給除傳送者以外的所有客戶端。

最佳化說明

  1. 心跳檢測

    • 使用 ScheduledExecutorService 每隔 30 秒檢查一次所有客戶端的最後活躍時間,如果某客戶端超過 1 分鐘未傳送訊息,則認為其超時,斷開連線。
  2. 執行緒池處理讀事件

    • handleRead 方法中的 I/O 操作被提交到 workerPool 執行緒池,避免阻塞主執行緒,實現併發處理。這樣即使某個客戶端 I/O 操作較慢,伺服器也能及時處理其他客戶端的請求。
  3. 使用 JSON 協議封裝訊息

    • 使用 Jackson ObjectMapper 將訊息物件 Message 轉換為 JSON 字串,並進行傳送和接收,這樣訊息內容更加結構化,客戶端可以透過 JSON 協議輕鬆解析訊息內容。

程式碼執行流程

  1. 啟動伺服器:初始化伺服器和選擇器,啟動心跳檢測任務。
  2. 連線和廣播:每當有新客戶端連線時,註冊為讀事件,並廣播加入訊息。讀事件被分配到執行緒池中處理,訊息被 JSON 序列化後廣播到其他客戶端。
  3. 心跳檢測:定期檢查客戶端是否超時,斷開長時間無響應的客戶端。
  4. 斷開連線:客戶端斷開連線或超時後,釋放相關資源並廣播退出訊息。

這種最佳化使得伺服器在高併發場景下更加健壯、靈活,並支援更精確的訊息協議。

小結一下

epoll的高效性主要得益於兩點:

  • 透過紅黑樹管理事件,實現事件的快速增刪查改操作。
  • 使用就緒佇列將活躍事件和非活躍事件分離,大幅減少不必要的系統呼叫。

好了,關於 epoll 多路複用你學會了嗎,原創不易,感謝支援,關注威哥愛程式設計,程式設計路上 V 哥與你一路同行。

相關文章