每个连接一个线程和每个请求一个线程

在本文中,我们比较了两种常用的服务器线程模型。在每个连接一个线程和每个请求一个线程模型之间的选择取决于应用程序的特定需求和预期的流量模式。一般来说,每连接一个线程为已知数量的客户端提供了简单性和可预测性,而每请求一个线程在可变或高负载条件下提供了更大的可伸缩性和灵活性。

在本教程中,我们将比较两种常用的服务器线程模型:每个连接一个线程和每个请求一个线程。

首先,我们将准确定义“连接”和“请求”。然后,我们将实现两个基于套接字的Java Web 服务器,并遵循不同的范例。最后,我们将讨论一些关键要点。

连接与请求线程模型
让我们从一些简明的定义开始。

线程模型是指程序如何以及何时创建和同步线程以实现并发和多任务处理的方法。为了说明这一点,我们以客户端和服务器之间的 HTTP 连接为例。我们将请求视为在建立连接期间客户端向服务器发出的一次单次执行。

当客户端需要与服务器通信时,它会实例化一个新的 HTTP-over-TCP 连接并发起新的 HTTP-over-TCP 请求。为了避免开销,如果连接已存在,客户端可以重用同一连接发送另一个请求。这种机制被称为 Keep-Alive,自 HTTP 1.0 以来就已存在,并在 HTTP 1.1 中成为默认行为。

理解了这个概念,我们现在可以介绍本文中比较的两个线程模型。

  • 如果我们使用“每个连接一个线程”范例,Web 服务器将如何为每个连接使用一个线程;
  • 而当采用“每个请求一个线程”模型时,Web 服务器将为每个请求使用一个线程,无论该请求是否属于现有连接

在接下来的章节中,我们将分析这两种方法的优缺点,并查看一些使用套接字的代码示例。这些示例将是真实案例的简化版本。为了尽可能简化代码,我们将避免引入在实际服务器架构中广泛使用的优化(例如线程池)。

了解每个连接的线程
采用“每个连接一个线程”的方法,每个客户端连接都会获得其专用的线程。同一个线程负责处理来自该连接的所有请求。

让我们通过构建一个简单的基于 Java 套接字的服务器来说明每个连接线程模型的工作原理:

public class ThreadPerConnectionServer {
   
   private static final int PORT = 8080;
   
   public static void main(String[] args) {
      try (ServerSocket serverSocket = new ServerSocket(PORT)) {
         logger.info("Server started on port {}", PORT);
            while (!serverSocket.isClosed()) {
               try {
                  Socket newClient = serverSocket.accept();
                  logger.info(
"New client connected: {}", newClient.getInetAddress());
                  ClientConnection clientConnection = new ClientConnection(newClient);
                  new ThreadPerConnection(clientConnection).start();
               } catch (IOException e) {
                  logger.error(
"Error accepting connection", e);
               }
            }
         } catch (IOException e) {
            logger.error(
"Error starting server", e);
         }
      }
   }
}

ClientConnection是一个简单的包装器,它实现了Closeable接口,并且包括我们将用来读取请求和写回响应的BufferedReader和PrintWriter : 

public class ClientConnection implements Closeable {
   // ...
   public ClientConnection(Socket socket) throws IOException {
      this.socket = socket;
      this.reader = new BufferedReader(new InputStreamReader(socket.getInputStream()));
      this.writer = new PrintWriter(socket.getOutputStream(), true);
    }
   @Override
   public void close() throws IOException {
      try (Writer writer = this.writer; Reader reader = this.reader; Socket socket = this.socket) {
         
// resources all closed when this block exits
      }
   }
}

ThreadPerConnectionServer在端口8080上创建一个ServerSocket,并重复调用accept()方法,该方法阻止执行,直到接收到新的连接。

当客户端连接时,服务器立即启动一个新的ThreadPerConnection线程:

public class ThreadPerConnection extends Thread {
   // ...
   
   @Override
   public void run() {
      try (ClientConnection client = this.clientConnection) {
         String request;
         while ((request = client.getReader().readLine()) != null) {
            Thread.sleep(1000);
// simulate server doing work
            logger.info(
"Processing request: {}", request);
            clientConnection.getWriter()
               .println(
"HTTP/1.1 200 OK - Processed request: " + request);
            logger.info(
"Processed request: {}", request);
         }
      } catch (Exception e) {
         logger.error(
"Error processing request", e);
      }
   }
}

这个简单的实现从客户端读取输入,并使用响应前缀回显它。当没有更多的请求从这个单一连接传入时,套接字将自动关闭,利用try-with-resource语法。每个连接都有自己的专用线程,而while循环中的主线程仍然可以自由地接受新的连接。

每连接一个线程模型最显著的优点是它的极端整洁和易于实现。如果10个客户端创建10个并发连接,则Web服务器需要10个线程来同时为它们提供服务。如果同一个线程服务于同一个用户,应用程序可以避免线程上下文切换。

了解每个请求的线程数
对于每个请求一个线程的模型,使用不同的线程来处理每个请求,即使使用的连接是持久的。

与前一个案例一样,让我们看一个简化的基于Java套接字的服务器的示例,该服务器采用了每请求一个线程的线程模型:

public class ThreadPerRequestServer {
   // ...
   
   public static void main(String[] args) {
      List<ClientSocket> clientSockets = new ArrayList<ClientSocket>();
      try (ServerSocket serverSocket = new ServerSocket(PORT)) {
         logger.info(
"Server started on port {}", PORT);
         while (!serverSocket.isClosed()) {
            acceptNewConnections(serverSocket, clientSockets);
            handleRequests(clientSockets);
         }
      } catch (IOException e) {
         logger.error(
"Server error: {}", e.getMessage());
      } finally {
         closeClientSockets(clientSockets);
      }
   }
}

在这里,我们维护一个clientSockets列表,而不是像以前那样只维护一个。服务器接受新的连接,直到服务器套接字关闭,处理所有来自它们的请求。当服务器套接字关闭时,我们还需要关闭每个仍然活动的客户端套接字连接(如果有的话)。

首先,让我们定义接受新连接的方法:

private static void acceptNewConnections(ServerSocket serverSocket, List<Socket> clientSockets) 
  throws SocketException {
   serverSocket.setSoTimeout(100);
   try {
      Socket newClient = serverSocket.accept();
      ClientConnection clientConnection = new ClientConnection(newClient);
      clientConnections.add(clientConnection);
      logger.info("New client connected: {}", newClient.getInetAddress());
   } catch (IOException ignored) {
     
// ignored
   }
}

理论上,接受新连接的方法和处理请求的方法应该在两个不同的主线程中执行

在这个简单的例子中,为了不阻塞唯一的主线程和执行流,我们需要在服务器上设置一个短的套接字超时。

如果在100ms内没有收到连接,我们认为没有可用的连接,并继续使用下一个用于处理请求的方法:

private static void handleRequests(List<Socket> clientSockets) throws IOException {
   Iterator<ClientConnection> iterator = clientSockets.iterator();
   while (iterator.hasNext()) {
      Socket clientSocket = iterator.next();
      if (clientSocket.getSocket().isClosed()) {
         logger.info("Client disconnected: {}", clientSocket.getInetAddress());
         iterator.remove();
         continue;
      }
      try {
         BufferedReader reader = client.getReader();
         if (reader.ready()) {
            String request = reader.readLine();
            if (request != null) {
               new ThreadPerRequest(client.getWriter(), request).start();
            }
         }
      } catch (IOException e) {
         logger.error(
"Error reading from client {}", client.getSocket()
            .getInetAddress(), e);
      }
   }
}

在这个方法中,对于每个包含新的有效请求的连接,我们启动一个新的线程,只处理单个请求:

public class ThreadPerRequest extends Thread {
   // ...
   @Override
   public void run() {
      try {
         Thread.sleep(1000);
// simulate server doing work
         logger.info(
"Processing request: {}", request);
         writer.println(
"HTTP/1.1 200 OK - Processed request: " + request);
         logger.info(
"Processed request: {}", request);
      } catch (Exception e) {
         logger.error(
"Error processing request: {}", e.getMessage());
      }
   }
}

在ThreadPerRequest中,我们不关闭客户端连接,并且只处理一个请求。一旦请求被处理,短生存期线程将被关闭。请注意,在使用线程池的实际应用程序服务器中,当请求结束时,线程不会停止,但它将被另一个请求重用。

使用这种线程模型,服务器可能会创建很多线程,它们之间有很高的上下文切换,但通常会更好地扩展:我们没有并发连接的上限。

对比表
下表比较了这两种方法,并考虑了服务器架构的一些决定性方面:

线程执行

  • 每个连接的线程:长寿命,仅在连接关闭时挂起    
  • 每个请求的线程数:短暂存在,处理完请求后立即挂起

上下文切换:

  • 每个连接的线程:低,受并发连接数的限制    
  • 每个请求的线程数:针对每个请求的高速上下文切换

扩展性:

  • 每个连接的线程:限制服务器可以创建的连接数量    
  • 每个请求的线程数:效率高,可以很好地扩展。

适用性:

  • 每个连接的线程:已知连接数    
  • 每个请求的线程数:不同的请求量

如果JVM提供的最大线程数是N,并且我们采用每连接一个线程的方式,那么我们最多将有N个并发客户端。一个额外的客户端需要等待,直到一个客户端断开连接,这可能需要很多时间。相反,如果我们采用每请求一个线程的方法,我们将有最多N个同时处理的请求。一个额外的请求保持排队,直到一个请求完成,这通常需要很短的时间。

最后:

  • 如果连接的数量是已知的,那么每连接一个线程的模型工作得很好:实现的简单性和低上下文切换产生了很好的影响。当在不可预测的突发中处理大量请求时,应该选择每个请求一个线程的模型。