如何通过sockets和select模块管理聊天服务器的socket连接(Python)
抱歉打扰大家,但我最近遇到了一些困惑。
我的问题是,我决定重新配置一个聊天程序,使用套接字(socket),让它有一个服务器,然后两个独立的客户端,而不是一个客户端和一个服务器/客户端。
我之前问过如何让我的服务器“管理”这些客户端的连接,以便它可以在它们之间转发数据。我得到了一个很棒的回答,给了我看起来正好需要的代码。
问题是我不太明白这些代码是怎么工作的,我在评论中问过,但除了几个文档链接外,没得到太多回复。
这是我得到的代码:
connections = []
while True:
rlist,wlist,xlist = select.select(connections + [s],[],[])
for i in rlist:
if i == s:
conn,addr = s.accept()
connections.append(conn)
continue
data = i.recv(1024)
for q in connections:
if q != i and q != s:
q.send(data)
根据我的理解,select模块可以让我们创建可以等待的对象,特别是在使用select.select的时候。
我有一个rlist,表示待读取的列表;一个wlist,表示待写入的列表;还有一个xlist,表示待处理的异常情况。
他把待写入的列表赋值给“s”,在我聊天服务器的部分,这个“s”是监听指定端口的套接字。
这大概是我能理解的部分了。但我真的非常希望能得到一些解释。
如果你觉得我问的问题不合适,请在评论中告诉我,我会删除它。我不想违反任何规则,而且我很确定我没有重复提问,因为我在求助之前确实做了一些研究。
谢谢大家!
1 个回答
注意:我这里的解释假设你在讨论的是TCP套接字,或者至少是某种基于连接的套接字。UDP和其他数据报(即非连接型)套接字在某些方面是相似的,但在使用select
时会有些不同。
每个套接字就像一个打开的文件,可以读取和写入数据。你写入的数据会进入系统内部的一个缓冲区,等待通过网络发送出去。而从网络上到达的数据会在系统内部被缓冲,直到你去读取它。虽然底层有很多复杂的东西在运作,但当你使用套接字时,这些就是你需要知道的(至少一开始是这样)。
记住系统在进行缓冲是很有用的,因为你会意识到操作系统中的TCP/IP协议栈是独立于你的应用程序发送和接收数据的——这样做是为了让你的应用程序有一个简单的接口(套接字就是这样一种方式,它隐藏了所有TCP/IP的复杂性)。
一种读取和写入数据的方式是阻塞。使用这种方式,当你调用recv()
时,如果系统中有等待的数据,它会立即返回。但是,如果没有数据等待,那么这个调用就会阻塞——也就是说,你的程序会停下来,直到有数据可以读取。有时候你可以设置一个超时,但在纯阻塞的输入输出中,你可能会一直等待,直到另一端发送数据或关闭连接。
这种方式在一些简单的情况下还不错,但只适用于与一台机器通信的情况——当你与多个套接字通信时,你不能只等待一台机器的数据,因为另一台可能正在发送数据。此外,还有其他问题,我在这里不详细讨论——总之,这不是一个好的方法。
一个解决方案是为每个连接使用不同的线程,这样阻塞就没问题了——其他连接的线程可以被阻塞而不会互相影响。在这种情况下,你需要为每个连接准备两个线程,一个用于读取,一个用于写入。然而,线程可能会很麻烦——你需要仔细同步它们之间的数据,这可能会让编码变得有点复杂。此外,对于这样一个简单的任务来说,线程的效率也不是很好。
select
模块为这个问题提供了一个单线程的解决方案——它允许你使用一个函数,告诉你“在这些套接字中,至少有一个有数据可以读取时再醒来”(这是一个简化的说法,我稍后会纠正)。所以,一旦调用select.select()
返回,你可以确定你正在等待的某个连接有数据,你可以安全地读取它(即使在阻塞输入输出的情况下,如果你小心的话——因为你确定那里有数据,所以你不会再阻塞等待它)。
当你第一次启动应用程序时,你只有一个套接字,那就是你的监听套接字。所以,你只需将它传递给select.select()
的调用。之前我简化的地方是,实际上这个调用接受三个套接字列表,分别用于读取、写入和错误。第一个列表中的套接字会被监视以供读取——所以,如果它们中有任何一个有数据可读,select.select()
函数就会将控制权返回给你的程序。第二个列表用于写入——你可能会认为可以随时写入套接字,但实际上如果连接的另一端没有足够快地读取数据,那么系统的写入缓冲区可能会填满,你可能会暂时无法写入。看起来给你代码的人忽略了这个复杂性,这对于一个简单的例子来说还好,因为通常缓冲区足够大,你不太可能在简单情况下遇到问题,但这是你在其他代码正常工作后应该解决的问题。最后一个列表用于监视错误——这个不常用,所以我暂时跳过。这里传递空列表是可以的。
此时,有人连接到你的服务器——就select.select()
而言,这算是让监听套接字“可读”,所以函数返回,返回的可读套接字列表(第一个返回值)将包括监听套接字。
接下来的部分会遍历所有有数据可读的连接,你可以看到监听套接字s
的特殊情况。代码在它上面调用accept()
,这将从监听套接字中获取下一个等待的新连接,并将其转换为一个全新的套接字(监听套接字继续监听,可能还有其他新的连接在等待,但这没问题——我稍后会讲)。这个全新的套接字被添加到connections
列表中,这就是处理监听套接字的结束——continue
将继续处理从select.select()
返回的下一个连接(如果有的话)。
对于其他可读的连接,代码在它们上调用recv()
以获取下一个1024
字节(或者如果少于1024字节则获取可用的字节)。重要提示——如果你没有使用select.select()
确保连接是可读的,这个recv()
调用可能会阻塞,导致你的程序停下来,直到特定连接上有数据到达——希望这能说明为什么需要select.select()
。
一旦读取了一些数据,代码会遍历所有其他连接(如果有的话),并使用send()
方法将数据发送到它们。代码正确地跳过了刚刚接收到数据的连接(这是q != i
的意思),同时也跳过了s
,但实际上这不是必需的,因为据我所知,它从未被添加到connections
列表中。
处理完所有可读连接后,代码会返回到select.select()
循环中,等待更多数据。注意,如果一个连接仍然有数据,调用会立即返回——这就是为什么从监听套接字只接受一个连接是可以的。如果还有更多连接,select.select()
会再次立即返回,循环可以处理下一个可用连接。你可以使用非阻塞输入输出来提高效率,但这会让事情变得更复杂,所以我们暂时保持简单。
这是一个合理的示例,但不幸的是,它存在一些问题:
- 正如我提到的,代码假设你总是可以安全地调用
send()
,但如果你有一个连接的另一端没有正确接收(可能那台机器过载),那么你的代码可能会填满发送缓冲区,然后在尝试调用send()
时挂起。 - 代码没有处理连接关闭的情况,这通常会导致
recv()
返回一个空字符串。这应该导致连接被关闭并从connections
列表中移除,但这段代码没有做到。
我稍微更新了代码,以尝试解决这两个问题:
connections = []
buffered_output = {}
while True:
rlist,wlist,xlist = select.select(connections + [s],buffered_output.keys(),[])
for i in rlist:
if i == s:
conn,addr = s.accept()
connections.append(conn)
continue
try:
data = i.recv(1024)
except socket.error:
data = ""
if data:
for q in connections:
if q != i:
buffered_output[q] = buffered_output.get(q, b"") + data
else:
i.close()
connections.remove(i)
if i in buffered_output:
del buffered_output[i]
for i in wlist:
if i not in buffered_output:
continue
bytes_sent = i.send(buffered_output[i])
buffered_output[i] = buffered_output[i][bytes_sent:]
if not buffered_output[i]:
del buffered_output[i]
我在这里应该指出,我假设如果远程端关闭了连接,我们也希望立即关闭。严格来说,这忽略了TCP的半关闭的可能性,即远程端发送了请求并关闭了它的端,但仍然期待数据返回。我相信非常旧的HTTP版本有时会这样做,以指示请求的结束,但实际上这种情况现在很少使用,可能与你的示例无关。
另外,值得注意的是,很多人在使用select
时会将套接字设置为非阻塞——这意味着调用recv()
或send()
时,如果会阻塞,则会返回一个错误(在Python中引发异常)。这样做部分是出于安全考虑,以确保不小心的代码不会导致应用程序阻塞;但它也允许一些稍微更高效的方法,比如分多次读取或写入数据,直到没有剩余。使用阻塞输入输出,这种方式是不可能的,因为select.select()
调用只保证有一些数据可以读取或写入——它并不保证有多少。因此,在每个连接上,你只能安全地调用一次阻塞的send()
或recv()
,然后需要再次调用select.select()
来查看是否可以再次执行。监听套接字上的accept()
也是如此。
效率的提升通常只在有大量繁忙连接的系统中才是个问题,因此在你的情况下,我建议保持简单,不用担心阻塞。如果你的应用程序似乎卡住并变得无响应,那么很可能是你在某个地方做了不该阻塞的调用。
最后,如果你想让这段代码更具可移植性和/或更快,可能值得看看像libev
这样的东西,它基本上提供了几种select.select()
的替代方案,适用于不同的平台。然而,原则上它们是相似的,所以最好先专注于select
,直到你的代码运行起来,然后再考虑后续的改动。
另外,我注意到有评论者建议使用Twisted,这是一个框架,提供了更高级的抽象,这样你就不需要担心所有细节。就我个人而言,我过去在使用它时遇到了一些问题,比如很难以方便的方式捕获错误,但很多人都成功地使用它——这只是看它的方式是否适合你的思维方式。至少值得调查一下,看看它的风格是否比我更适合你。我来自C/C++网络编程的背景,所以也许我只是坚持我所熟悉的(Python的select
模块与其基础的C/C++版本非常接近)。
希望我能充分解释这些内容——如果你还有问题,请在评论中告诉我,我可以在我的回答中添加更多细节。