从 urllib.request 向 HTTPServer 发出许多并发请求时的神秘异常
Posted
技术标签:
【中文标题】从 urllib.request 向 HTTPServer 发出许多并发请求时的神秘异常【英文标题】:Mysterious exceptions when making many concurrent requests from urllib.request to HTTPServer 【发布时间】:2016-07-04 16:39:15 【问题描述】:我正在尝试做this Matasano crypto challenge,这涉及对具有人为减慢字符串比较功能的服务器进行定时攻击。它说要使用“您选择的 web 框架”,但我不想安装 web 框架,所以我决定使用 http.server
模块中内置的 HTTPServer class。
我想出了一些可行的方法,但速度很慢,所以我尝试使用 multiprocessing.dummy
中内置的(文档不充分的)线程池来加速它。它要快得多,但我注意到一些奇怪的事情:如果我同时发出 8 个或更少的请求,它工作正常。如果我有更多,它会工作一段时间,并在看似随机的时间给我错误。这些错误似乎不一致且并不总是相同,但它们通常包含Connection refused, invalid argument
、OSError: [Errno 22] Invalid argument
、urllib.error.URLError: <urlopen error [Errno 22] Invalid argument>
、BrokenPipeError: [Errno 32] Broken pipe
或urllib.error.URLError: <urlopen error [Errno 61] Connection refused>
。
服务器可以处理的连接数是否有限制?我不认为线程数本身是问题,因为我编写了一个简单的函数,它在不运行 Web 服务器的情况下进行减慢的字符串比较,并使用 500 个并发线程调用它,它运行良好。我不认为简单地从这么多线程发出请求是问题,因为我已经制作了使用超过 100 个线程的爬虫(所有线程同时向同一个网站发出请求)并且它们运行良好。看起来可能 HTTPServer 并不是为了可靠地托管获得大量流量的生产网站,但我很惊讶它很容易崩溃。
我尝试逐渐从我的代码中删除看起来与问题无关的内容,就像我通常在诊断此类神秘错误时所做的那样,但这在这种情况下并不是很有帮助。似乎在我删除看似无关的代码时,服务器可以处理的连接数逐渐增加,但没有明确的崩溃原因。
有谁知道如何增加我一次可以发出的请求数量,或者至少为什么会这样?
我的代码很复杂,但我想出了一个演示问题的简单程序:
#!/usr/bin/env python3
import os
import random
from http.server import BaseHTTPRequestHandler, HTTPServer
from multiprocessing.dummy import Pool as ThreadPool
from socketserver import ForkingMixIn, ThreadingMixIn
from threading import Thread
from time import sleep
from urllib.error import HTTPError
from urllib.request import urlopen
class FancyHTTPServer(ThreadingMixIn, HTTPServer):
pass
class MyRequestHandler(BaseHTTPRequestHandler):
def do_GET(self):
sleep(random.uniform(0, 2))
self.send_response(200)
self.end_headers()
self.wfile.write(b"foo")
def log_request(self, code=None, size=None):
pass
def request_is_ok(number):
try:
urlopen("http://localhost:31415/test" + str(number))
except HTTPError:
return False
else:
return True
server = FancyHTTPServer(("localhost", 31415), MyRequestHandler)
try:
Thread(target=server.serve_forever).start()
with ThreadPool(200) as pool:
for i in range(10):
numbers = [random.randint(0, 99999) for j in range(20000)]
for j, result in enumerate(pool.imap(request_is_ok, numbers)):
if j % 20 == 0:
print(i, j)
finally:
server.shutdown()
server.server_close()
print("done testing server")
出于某种原因,上面的程序运行良好,除非它有超过 100 个线程左右,但我的挑战的真实代码只能处理 8 个线程。如果我使用 9 运行它,我通常会遇到连接错误,而使用 10 时,我总是会遇到连接错误。我尝试使用concurrent.futures.ThreadPoolExecutor
、concurrent.futures.ProcessPoolExecutor
和multiprocessing.pool
而不是multiprocessing.dummy.pool
,但这些似乎都没有帮助。我尝试使用普通的HTTPServer
对象(没有ThreadingMixIn
),这只是让事情运行得非常缓慢,并没有解决问题。我尝试使用ForkingMixIn
,但也没有解决。
我该怎么办?我在运行 OS X 10.11.3 的 2013 年末 MacBook Pro 上运行 Python 3.5.1。
编辑:我尝试了更多的东西,包括在进程而不是线程中运行服务器,作为简单的HTTPServer
,使用ForkingMixIn
,以及使用ThreadingMixIn
.这些都没有帮助。
编辑: 这个问题比我想象的要奇怪。我尝试用服务器制作一个脚本,另一个用大量线程发出请求,并在终端的不同选项卡中运行它们。服务器的进程运行良好,但发出请求的进程崩溃了。例外是ConnectionResetError: [Errno 54] Connection reset by peer
、urllib.error.URLError: <urlopen error [Errno 54] Connection reset by peer>
、OSError: [Errno 41] Protocol wrong type for socket
、urllib.error.URLError: <urlopen error [Errno 41] Protocol wrong type for socket>
、urllib.error.URLError: <urlopen error [Errno 22] Invalid argument>
的混合。
我在上面的虚拟服务器上进行了尝试,如果我将并发请求的数量限制为 5 个或更少,它工作正常,但有 6 个请求,客户端进程崩溃。服务器出现了一些错误,但它继续运行。无论我是使用线程还是进程来发出请求,客户端都会崩溃。然后我尝试将减速功能放在服务器中,它能够处理 60 个并发请求,但它崩溃了 70 个。这似乎与服务器问题的证据相矛盾。
编辑:我用requests
而不是urllib.request
尝试了我描述的大部分事情,并遇到了类似的问题。
编辑:我现在运行的是 OS X 10.11.4 并遇到了同样的问题。
【问题讨论】:
您是否确保关闭未使用的客户端连接? @Cory Shay,我试着做x = urlopen(whatever)
然后x.close()
,但这似乎没有帮助。
我必须承认,我所说的原因不一定是这个问题发生的原因。可能还有其他人。但是有几个问题可能有助于调查这个问题是“如果你发出 ulimit -r $(( 32 * 1024 ))
会发生什么?”和“netstat -anp|grep SERVERPROCESSNAME
的输出是什么?”
【参考方案1】:
您正在使用默认的 listen()
积压值,这可能是导致很多错误的原因。这不是已经建立连接的同时客户端的数量,而是在连接建立之前等待侦听队列的客户端数量。将您的服务器类更改为:
class FancyHTTPServer(ThreadingMixIn, HTTPServer):
def server_activate(self):
self.socket.listen(128)
128 是一个合理的限制。如果您想进一步增加它,您可能需要检查 socket.SOMAXCONN 或您的操作系统 somaxconn。如果您在重负载下仍然有随机错误,您应该检查您的 ulimit 设置并在需要时增加。
我用你的例子做了这个,我有超过 1000 个线程运行良好,所以我认为这应该可以解决你的问题。
更新
如果它有所改善,但它仍然与 200 个并发客户端崩溃,那么我很确定您的主要问题是积压的大小。请注意,您的问题不是并发客户端的数量,而是并发连接请求的数量。简要说明这意味着什么,无需深入了解 TCP 内部。
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.bind((HOST, PORT))
s.listen(BACKLOG)
while running:
conn, addr = s.accept()
do_something(conn, addr)
在这个例子中,套接字现在接受给定端口上的连接,s.accept()
调用将阻塞,直到客户端连接。您可以有许多客户端尝试同时连接,并且根据您的应用程序,您可能无法调用s.accept()
并在客户端尝试连接时尽可能快地分派客户端连接。待处理的客户端排队,该队列的最大大小由 BACKLOG 值确定。如果队列已满,客户端将失败并出现 Connection Refused 错误。
线程没有帮助,因为 ThreadingMixIn 类所做的是在单独的线程中执行do_something(conn, addr)
调用,因此服务器可以返回到主循环和s.accept()
调用。
您可以尝试进一步增加积压,但有时这无济于事,因为如果队列变得太大,一些客户端将在服务器执行 s.accept()
调用之前超时。
所以,正如我上面所说,您的问题是同时连接尝试的数量,而不是同时客户端的数量。也许 128 对您的实际应用程序来说就足够了,但是您在测试中遇到了错误,因为您试图一次连接所有 200 个线程并淹没队列。
不要担心ulimit
,除非您收到Too many open files
错误,但如果您想将积压工作增加到128 个以上,请对socket.SOMAXCONN
进行一些研究。这是一个好的开始:https://utcc.utoronto.ca/~cks/space/blog/python/AvoidSOMAXCONN
【讨论】:
我做到了,它可以工作,即使有 150 个线程!它以 200 崩溃,但 150 可能足以满足我的目的,如果不是,至少我可能知道该怎么做。我不知道这listen()
的作用,或者 somaxconn 或 ulimit 是什么,所以我想研究所有这些,尝试不同的数字,也许在奖励赏金之前等着看我是否能得到更好的答案,但你的回答很有帮助。谢谢。
@EliasZamaria 检查我更新的答案。因为你有点迷茫,所以我提供了更详细的解释。
感谢您的解释。这个 TCP 的东西比我通常处理的要低级,我对它了解不多。如果我遇到任何我自己无法轻松解决的问题,我会在有时间的时候再尝试一下并在这里发布。
谢谢。我不知何故忽略了这一点。我猜想在我的HTTPServer
子类中覆盖request_queue_size
将与覆盖server_activate
具有相同的效果,并且可以说更具可读性,所以我想我会这样做。
@EliasZamaria 不知道。至少从 Python 1.5.2 开始,socketserver 模块的默认值是 5。我猜当时它被接受为一个合理的默认值,当 socket.listen 的默认值更改为 min(socket.SOMAXCONN, 128)
时,没有人会打扰更新它。【参考方案2】:
我会说您的问题与一些 IO 阻塞有关,因为我已在 NodeJs 上成功执行了您的代码。我还注意到服务器和客户端都无法单独工作。
但是可以通过一些修改来增加请求的数量:
定义并发连接数:
http.server.HTTPServer.request_queue_size = 500
在不同的进程中运行服务器:
server = multiprocessing.Process(target=RunHTTPServer) server.start()
使用客户端的连接池来执行请求
使用服务器端的线程池来处理请求
通过设置架构和使用“keep-alive”标头,允许在客户端重用连接
通过所有这些修改,我设法以 500 个线程运行代码而没有任何问题。所以如果你想试一试,这里是完整的代码:
import random
from time import sleep, clock
from http.server import BaseHTTPRequestHandler, HTTPServer
from multiprocessing import Process
from multiprocessing.pool import ThreadPool
from socketserver import ThreadingMixIn
from concurrent.futures import ThreadPoolExecutor
from urllib3 import HTTPConnectionPool
from urllib.error import HTTPError
class HTTPServerThreaded(HTTPServer):
request_queue_size = 500
allow_reuse_address = True
def serve_forever(self):
executor = ThreadPoolExecutor(max_workers=self.request_queue_size)
while True:
try:
request, client_address = self.get_request()
executor.submit(ThreadingMixIn.process_request_thread, self, request, client_address)
except OSError:
break
self.server_close()
class MyRequestHandler(BaseHTTPRequestHandler):
default_request_version = 'HTTP/1.1'
def do_GET(self):
sleep(random.uniform(0, 1) / 100.0)
data = b"abcdef"
self.send_response(200)
self.send_header("Content-type", 'text/html')
self.send_header("Content-length", len(data))
self.end_headers()
self.wfile.write(data)
def log_request(self, code=None, size=None):
pass
def RunHTTPServer():
server = HTTPServerThreaded(('127.0.0.1', 5674), MyRequestHandler)
server.serve_forever()
client_headers =
'User-Agent' : 'Mozilla/5.0 (Windows NT 6.1; Win64; x64)',
'Content-Type': 'text/plain',
'Connection': 'keep-alive'
client_pool = None
def request_is_ok(number):
response = client_pool.request('GET', "/test" + str(number), headers=client_headers)
return response.status == 200 and response.data == b"abcdef"
if __name__ == '__main__':
# start the server in another process
server = Process(target=RunHTTPServer)
server.start()
# start a connection pool for the clients
client_pool = HTTPConnectionPool('127.0.0.1', 5674)
# execute the requests
with ThreadPool(500) as thread_pool:
start = clock()
for i in range(5):
numbers = [random.randint(0, 99999) for j in range(20000)]
for j, result in enumerate(thread_pool.imap(request_is_ok, numbers)):
if j % 1000 == 0:
print(i, j, result)
end = clock()
print("execution time: %s" % (end-start,))
更新 1:
增加 request_queue_size 只会给你更多的空间来存储当时无法执行的请求,以便稍后执行。 因此,队列越长,响应时间的分散就越大,我相信这与您的目标相反。 至于 ThreadingMixIn,它并不理想,因为它为每个请求创建和销毁一个线程,而且成本很高。减少等待队列的更好选择是使用可重用线程池来处理请求。
在另一个进程中运行服务器的原因是为了利用另一个CPU来减少执行时间。
对于客户端来说,使用 HTTPConnectionPool 是我发现保持恒定请求流的唯一方法,因为在分析连接时我对 urlopen 有一些奇怪的行为。
【讨论】:
我试过request_queue_size
,相当于Pedro建议的self.socket.listen
,似乎解决了我的问题。
我不知道http.server.HTTPServer.allow_reuse_address = True
应该做什么。似乎默认值为 1。见hg.python.org/cpython/file/3.5/Lib/http/server.py#l134
正如我在问题的编辑中提到的,我尝试在进程而不是线程中运行服务器,但这没有帮助。
我不确定线程池是否值得麻烦。我已经在使用ThreadingMixIn
。线程池是否不太可能导致问题?
我已经解释了更多关于选择的内容。顺便说一句,我无法在旧配置上运行您的代码。但是不要相信我的话并尝试一下。【参考方案3】:
标准是只使用与内核一样多的线程,因此需要 8 个线程(包括虚拟内核)。线程模型是最容易工作的,但它确实是一种垃圾方式。处理多个连接的更好方法是使用异步方法。不过比较难。
使用您的线程方法,您可以从调查退出程序后进程是否保持打开状态开始。这意味着您的线程没有关闭,并且显然会导致问题。
试试这个...
class FancyHTTPServer(ThreadingMixIn, HTTPServer):
daemon_threads = True
这将确保您的线程正确关闭。它很可能会在线程池中自动发生,但无论如何都值得尝试。
【讨论】:
首先,如果任务受 CPU 限制,而不是 I/O 限制,您将使用与内核一样多的线程。其次,由于 GIL,Python 线程一次只能在一个线程中运行。以上是关于从 urllib.request 向 HTTPServer 发出许多并发请求时的神秘异常的主要内容,如果未能解决你的问题,请参考以下文章