本文将从 TCP socket 出发,用 Python 手把手实现一个最小可用的 HTTP 服务器。

什么是 socket?

Socket(套接字)是操作系统提供给应用程序的网络通信接口,支持 TCP 和 UDP 协议。有了 socket,你只需要发送和接收数据,而无需关心底层网卡、路由等复杂细节。Python 也拥有操作 socket 的标准库,可以使用 socket 在 Python 中接收和发送数据。

客户端 Socket

# 用 netcat 建立一个 TCP 服务器,只要连接上就会发送 Hello world!
$ echo "Hello world!" | nc -lvnp 6767
import socket

client_socket = socket.socket(
    socket.AF_INET,		# 使用 IPv4
    socket.SOCK_STREAM	# 使用面向流的TCP协议
)

# 连接到 127.0.0.1:6767
client_socket.connect(('127.0.0.1', 6767))
# 一次性接收最大 1024 字节的数据
print(client_socket.recv(1024))
# b'Hello world!\r\n'

服务端 Socket

import socket

server_socket = socket.socket(
    socket.AF_INET,		# 使用 IPv4
    socket.SOCK_STREAM	# 使用面向流的TCP协议
)
# 允许端口复用,避免重启时 "Address already in use"
server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
# 绑定到 127.0.0.1:6767,此时还没有开始监听
server_socket.bind(('127.0.0.1', 6767))
# 开始监听,括号里填一个整数可以指定最大同时连接数
server_socket.listen()
print('服务器正在运行')

while True: # 重复等待连接
    conn, addr = server_socket.accept()
    print(f'{ addr } 已连接')
    conn.sendall(b'Hello world!') # 发送完整的数据
    conn.close()
$ nc 127.0.0.1 6767
Hello world!

HTTP 协议(摘自 MDN)

HTTP 是一种用作获取诸如 HTML 文档这类资源的协议。它是 Web 上进行任何数据交换的基础,同时,也是一种客户端—服务器(client-server)协议,也就是说,请求是由接受方——通常是 Web 浏览器——发起的。完整网页文档通常由文本、布局描述、图片、视频、脚本等资源构成。

HTTP 请求

HTTP 请求

请求由以下元素组成:

  • HTTP 方法,通常是由一个动词,像 GET、POST 等,或者一个名词,像 OPTIONS、HEAD 等,来定义客户端执行的动作。典型场景有:客户端意图获取某个资源(使用 GET);发送 HTML 表单的参数值(使用 POST);以及其他情况下需要的那些其他操作。
  • 要获取的那个资源的路径——去除了当前上下文中显而易见的信息之后的 URL,比如说,它不包括协议(http://)、域名(这里是 developer.mozilla.org),或是 TCP 的端口(这里是 80)。
  • HTTP 协议版本号。
  • 为服务端表达其他信息的可选标头。
  • 请求体(body),类似于响应中的请求体,一些像 POST 这样的方法,请求体内包含需要了发送的资源。

HTTP 响应

HTTP 响应

响应报文包含了下面的元素:

  • HTTP 协议版本号。
  • 状态码,来指明对应请求已成功执行与否,以及不成功时相应的原因。
  • 状态信息,这个信息是一个不权威、简短的状态码描述。
  • HTTP 标头,与请求标头类似。
  • 可选项,一个包含了被获取资源的主体。

异步

socket.accept 函数是会阻塞程序的,因此在循环中,只有有新的连接加入才会从 accept 的那一行跳到下一行,否则将会一直等待。但是服务器是要同时和多个客户端进行交互的,需要在等待新连接的同时处理已连接的 socket,因此这样行不通。

其中一种解决办法是使用多线程,等待新连接是一个线程,每当有新的连接就新建一个线程专门处理这个连接,这样就不会因为一直等待新的连接而阻断别的事务处理了。

import socket
import threading

def handle_client(conn: socket.socket, addr: tuple):
    # 处理单个客户端连接(在独立线程中运行)
    try:
        data = conn.recv(4096)
        conn.sendall(b'Hello ' + data)
    except Exception as e:
        print(f'{addr} 错误:{e}')
    finally:
        conn.close()
        print(f'{addr} 断连')

server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# 允许端口复用,避免重启时 "Address already in use"
server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server_socket.bind(("127.0.0.1", 6767))
server_socket.listen(5)
print("服务器运行在 127.0.0.1:6767")

while True:
    conn, addr = server_socket.accept()
    print(f"{addr} 已连接")
    # 每个连接启动一个守护线程,主线程不会被阻塞
    # 当进程中所有非守护线程(即主线程和其他普通线程)都结束时,Python 解释器会立即退出,不会等待守护线程完成。
    t = threading.Thread(target=handle_client, args=(conn, addr), daemon=True)
    t.start()

但是给每一个会话都分配一个线程实在是太浪费了,一个会话不可能频繁地发消息,在会话空闲的时候,这个线程就无事可做了,在这种低强度的并发需求下,给每一个会话分配一个线程会造成极大浪费。

那有没有一种方案,能把等待新连接和等待会话的新事务的空闲时间利用起来呢?有的,这就是异步。

协程

协程又叫微线程,在执行的过程中,可以随时中断,让事件循环去处理下一个任务,这样可以做到一个线程同时做多个事情。

需要注意的是,异步提升的并不是 CPU 的计算速度,而是 IO 等待期间的利用率。当服务器在等待某个客户端发送数据时,CPU 处于完全空闲状态;异步模型允许事件循环在此期间切换去处理其他已就绪的连接,从而在单线程内实现高并发。如果所有任务都是纯 CPU 计算、没有任何 IO 等待,那么串行执行和异步执行的总耗时确实相同——但这不是网络服务器的典型场景。

asyncio(代码摘自菜鸟教程)

asyncio 是 Python 的一个内置库,主要用于编写单线程并发代码,通过协程实现异步 I/O 操作。它在 Python 3.4 版本中引入,提供了基于事件循环的并发模型。

定义协程

引入 asyncio 库后,在 def 前加上 async 关键字即可把该函数定义为协程。

import asyncio

async def say_hello():
    print("Hello")
    await asyncio.sleep(1)
    print("World")

事件循环

事件循环是 asyncio 的核心组件,负责调度和执行协程。它不断地检查是否有任务需要执行,并在任务完成后调用相应的回调函数。

async def main():
    await say_hello()

asyncio.run(main())

任务

任务是对协程的封装,表示一个正在执行或将要执行的协程。你可以通过 asyncio.create_task() 函数创建任务,并将其添加到事件循环中。

async def main():
    task = asyncio.create_task(say_hello())
    await task

Future

Future 是一个表示异步操作结果的对象。它通常用于底层 API,表示一个尚未完成的操作。你可以通过 await 关键字等待 Future 完成。Future 是更底层的机制,通常由库内部使用,你暂时不需要直接操作。

async def main():
    future = asyncio.Future()
    await future

asyncio 对 socket 进行了封装

asyncio.start_server() 对 socket 进行了封装,可以用来创建一个异步的 TCP 服务器,包含了自动接受连接、自动处理连接的关闭与异常等功能:

async def handle_client(reader, writer):
    ...
server = await asyncio.start_server(handle_client, '0.0.0.0', 4000)
async with server:
    await server.serve_forever()

不过,为了更深入地理解异步 IO 与 socket 的底层交互原理,这里我们采用手动结合事件循环 (loop.sock_accept / loop.sock_recv / loop.sock_sendall) 操作原生 socket 的写法。

开始写代码

使用 asyncio 处理 socket 的话,一定要设置 socket.setblocking(False),将服务器套接字设置为非阻塞模式,这是配合 asyncio 事件循环的必要前提。若保持默认阻塞模式,socket 等待时会卡住线程。这会导致整个事件循环停滞。其他并发任务也无法被处理。设为非阻塞后,可结合 await 异步方法使用。IO 等待时便能主动让出控制权。事件循环就能继续调度其他协程。最终实现单线程下的高并发处理。

同时,socket.accept()socket.sendall() 等方法不能直接调用。它们会绕过事件循环并可能引发异常。必须替换为对应的异步版本。首先,需要使用 asyncio.get_running_loop() 函数获取当前正在运行的事件循环对象,然后使用 await loop.sock_accept() 接收连接,使用 await loop.sock_sendall() 发送数据。这样才能保证 IO 操作真正融入异步调度机制。

以下是基础的主事件循环:

async def main():
    # 获取当前正在运行的事件循环对象
    loop = asyncio.get_running_loop()

    server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    server_socket.bind(('0.0.0.0', 4000))
    server_socket.listen()
    server_socket.setblocking(False) # 设置为非阻塞模式
    print(f'Server running at http://127.0.0.1:4000')

    while True:
        # 异步等待新连接
        conn, addr = await loop.sock_accept(server_socket)
        print(f'[+] { addr } connected')
        # 添加到事件循环
        asyncio.create_task(handle_client(conn, addr))

asyncio.run(main()) # `main()` 函数是协程,不能直接运行

现在主循环写好了,该写 handle_client() 函数了。该函数是一个协程,用来 处理单个客户端连接的 HTTP 请求并返回响应(本示例省略了请求解析步骤):

async def handle_client(conn: socket.socket, addr):
    loop = asyncio.get_running_loop()

    try:
        while True:
            recv = await loop.sock_recv(conn, 4096)
            if not recv: # 在 TCP 中,空数据代表对方优雅关闭了连接
                break
            await loop.sock_sendall(conn, http_response)
    except ConnectionResetError: # 对方强制关闭了连接
        pass # 引发错误已经打断了 while 循环,pass 到 finally 中
    finally:
        conn.close()
        print(f'[-] { addr } disconnected')

现在,我们可以专注于 http_response 的编写。该变量应该是一串 bytes 类型的 HTTP 响应。这里,我们读取当前目录下的 index.html,并作为响应主体进行发送:

with open('index.html', 'rb') as f:
    html = f.read()

http_response = bytes(f'HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: { len(html) }\r\n\r\n'.encode() + html)

完整代码

main.py

import socket
import asyncio

with open('index.html', 'rb') as f:
    html = f.read()

http_response = bytes(f'HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: { len(html) }\r\n\r\n'.encode() + html)

async def handle_client(conn: socket.socket, addr):
    loop = asyncio.get_running_loop()

    try:
        while True:
            recv = await loop.sock_recv(conn, 4096)
            if not recv: # 对方优雅关闭了连接
                break
            await loop.sock_sendall(conn, http_response)
    except ConnectionResetError: # 对方强制关闭了连接
        pass
    finally:
        conn.close()
        print(f'[-] { addr } disconnected')

async def main():
    loop = asyncio.get_running_loop()

    server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    server_socket.bind(('0.0.0.0', 4000))
    server_socket.listen()
    server_socket.setblocking(False)
    print(f'Server running at http://127.0.0.1:4000')

    while True:
        conn, addr = await loop.sock_accept(server_socket)
        print(f'[+] { addr } connected')
        asyncio.create_task(handle_client(conn, addr))

asyncio.run(main())

index.html

<!doctype html>
<html lang="en">
    <head>
        <meta charset="UTF-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>Socket HTTP Server</title>
        
        <style>
            html {
                font-size: max(16px, 2vw);
            }

            .container {
                margin: 32px;
            }
        </style>

    </head>
    <body>
        <div class="container">
            <h1>This is a HTTP server based on socket!</h1>
            <p>No frameworks. No libraries. Just raw sockets and asyncio.</p>
            <h2>How it works</h2>
            <ol>
                <li>
                    <code>socket.socket()</code> creates a TCP listener on port
                    4000
                </li>
                <li>
                    <code>asyncio</code> handles multiple connections
                    concurrently
                </li>
            </ol>
            <h2>Stack</h2>
            <ul>
                <li>Python 3</li>
                <li>socket + asyncio</li>
                <li>HTTP/1.1</li>
            </ul>
        </div>
    </body>
</html>

最终效果

最终效果

这样,我们就完成了一个基于 asyncio 和原生 socket 的最小可用 HTTP 服务器的实现。你还可以继续完善它,比如让它不仅仅只响应 index.html,而是根据请求的路径来响应正确的内容;以及异常处理等功能。