注意:我已经在先前的分期付款中添加了目录,因此希望它们更容易导航。感谢Derlin的nifty TOC generation tool!
- Security Note
- Basic Server
- Permissions Dropping
- Socket Server
- A Thread Story or GIL Steals Your Lunch Money
- Multiprocessing
- Blocking and Polling via Selector
- Conclusion
到目前为止,我们已经看到了网络的基本通信模式以及构成Internet通信核心的三个低级协议。现在,我们将研究服务器的运行方式。服务器是从Back Back Days到托管数百万客户的现代网络服务器的公告板系统(BBS)。本文介绍的将是一台简单的Python服务器,并慢慢地为其提供数据的方式添加了更多功能。
安全说明
这里的代码不会通过操纵客户数据的方式来防止恶意攻击。它仅是为了显示每种类型的服务器工作方式的基础知识。如果您正在使用公共面对服务的服务,则实际上应该有反向代理,甚至在其前面有防火墙来处理此类攻击。我通常更喜欢在该层进行此操作,因为在易于更新软件中处理网络硬化要比试图在谁知道多少代码库中处理它更容易。因此基本上:
不要在生产中使用任何这些
基本服务器
大多数服务器的工作流程:
- 绑定到零件
- 开始收听流量
- 接受连接
- 处理连接
- 关闭连接
因此,我们将从Echo服务器开始,该服务器只需将其发送给客户端回复。这是python documentation的一些示例代码:
# Echo server program
import socket
HOST = '' # Symbolic name meaning all available interfaces
PORT = 50007 # Arbitrary non-privileged port
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind((HOST, PORT))
s.listen(1)
conn, addr = s.accept()
with conn:
print('Connected by', addr)
while True:
data = conn.recv(1024)
if not data: break
conn.sendall(data)
和客户:
# Echo client program
import socket
HOST = 'localhost' # The remote host
PORT = 50007 # The same port as used by the server
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.connect((HOST, PORT))
s.sendall(b'Hello, world')
data = s.recv(1024)
print('Received', repr(data))
结果是:
> python .\simple_server.py
Connected by ('127.0.0.1', 53811)
>
> python .\simple_client.py
Received b'Hello, world'
>
继续这样做之前,我想花点时间讨论端口绑定权限。
权限下降
值得注意的一件事是根据IANA well known ports listing实际上有一个特定的端口号7,该端口编号7被指定为Echo服务器。如果我尝试以非特权用户的身份将其绑定在Windows中:
TCP 0.0.0.0:7 0.0.0.0:0 LISTENING 24664
它愉快地符合请求(尽管您可能需要一次Windows防火墙例外)。另一方面,Linux:
s.bind((HOST, PORT))
PermissionError: [Errno 13] Permission denied
端口绑定被拒绝。这将发生在大多数 *NIX类似系统上。现在,我们可以将其作为根来解决问题:
# python3 SimpleServer/simple_server.py
Server bound to port 7
,但总的来说,并不是真正的运行服务,因为如果有人设法利用服务器,他们可能会完全控制系统。为了解决这个问题,我们可以使用OS.SetUID和OS.SETGID。然后,代码成为这样:
# Echo server program
import socket, os, pwd, grp
HOST = '' # Symbolic name meaning all available interfaces
PORT = 7 # Well known echo port
# https://stackoverflow.com/a/2699996
def drop_privileges(uid_name='nobody', gid_name='nogroup'):
if os.getuid() != 0:
# We're not root so, like, whatever dude
return
# Get the uid/gid from the name
running_uid = pwd.getpwnam(uid_name).pw_uid
running_gid = grp.getgrnam(gid_name).gr_gid
# Remove group privileges
os.setgroups([])
# Try setting the new uid/gid
os.setgid(running_gid)
os.setuid(running_uid)
# owner/group r+w
old_umask = os.umask(0x007)
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind((HOST, PORT))
print(f'Server bound to port {PORT}')
drop_privileges()
s.listen(1)
conn, addr = s.accept()
with conn:
print('Connected by', addr)
while True:
data = conn.recv(1024)
if not data: break
conn.sendall(data)
这将使特定用户的权限降低,默认情况下,“没有人”和“非群落”。 pwd.getpwnam调用在UNIX密码数据库中获得了用户的条目(大多数时间是/etc/passwd
),而grp.getgrnam对于UNIX组数据库也相同(大多数时间是/etc/group
)。运行此操作后,我们可以看到端口被绑定,但是该过程以nobody
:
的方式运行
# python3 SimpleServer/simple_server_drop_priv.py
Server bound to port 7
$ pgrep -a -u nobody
285691 python3 SimpleServer/simple_server_drop_priv.py
umask
与该过程创建的文件和目录的权限有关。我设置的007
允许用户和组可以完全访问这些文件,而所有其他用户都被阻止了访问。这意味着我可以将流程组更改为“ serveradmin”之类的东西,这些组中的用户将能够与服务器的文件进行交互。亚历克斯·华雷斯(Alex Juarez)对一般的许可有一个good article。此堆栈溢出答案也对nuances of how umask operates进行了有趣的了解。
插座服务器
现在,现有服务器的问题是它立即退出,只处理一个连接。对于需要不断为客户提供服务的Web服务器,此功能是不切实际的。现在,我们可以进行一些修改以使其不断提供连接:
# Echo server program
import socket
HOST = '' # Symbolic name meaning all available interfaces
PORT = 9999 # Arbitrary non-privileged port
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind((HOST, PORT))
s.listen()
while True:
conn, addr = s.accept()
print('Connected by', addr)
with conn:
data = conn.recv(1024).strip()
print("{} wrote:".format(addr))
print(data)
conn.sendall(data)
,但相当低的水平不是非常可扩展的。值得庆幸的是,Python拥有socket server模块,可以在设置服务器时提供抽象。 Python文档也有一个socket server的示例:
import socketserver
class MyTCPHandler(socketserver.BaseRequestHandler):
"""
The request handler class for our server.
It is instantiated once per connection to the server, and must
override the handle() method to implement communication to the
client.
"""
def handle(self):
# self.request is the TCP socket connected to the client
self.data = self.request.recv(1024).strip()
print("{} wrote:".format(self.client_address[0]))
print(self.data)
# just send back the same data, but upper-cased
self.request.sendall(self.data.upper())
if __name__ == "__main__":
HOST, PORT = "localhost", 9999
# Create the server, binding to localhost on port 9999
with socketserver.TCPServer((HOST, PORT), MyTCPHandler) as server:
# Activate the server; this will keep running until you
# interrupt the program with Ctrl-C
server.serve_forever()
虽然服务器创建本质上仍然更加急切,但现在通过继承socketserver.BaseRequestHandler的对象来处理客户端连接。这需要实现类来定义handle()方法,该方法将使TCP公开self.request
保持连接的插座。现在,要显示多个工作连接,我将使用Apache HTTP server benchmarking tool。这很容易通过sudo apt-get install apache2-utils
在Ubuntu中获得:
$ ab -i -n 20 http://172.18.128.1:9999/
这将向服务器提出几个简短的HTTP请求(我已经调整了代码以绑定到适当的IP地址)。 20个请求将被执行到服务器,我们可以在此处看到:
作为一种基准测试工具,我们还获得了一些不错的统计数据,主要是:
Requests per second: 1992.99 [#/sec] (mean)
与简单的服务器无限循环版本进行了比较:
Requests per second: 1745.88 [#/sec] (mean)
现在,尽管代码布局改进了,但仍然存在一次处理多个客户端的问题。处理此操作的一种有趣的方法是将套接字接受和实际客户处理程序分开。
线程故事或吉尔偷了你的午餐钱
线程是查看此问题的一种方法。简短的故事是,如果没有本机线程,它使线程的性能不如语言那样表现。长话是another full article。 SocketServer周围有一个线程服务器包装以帮助:
import threading
import socketserver
class ThreadedTCPRequestHandler(socketserver.BaseRequestHandler):
def handle(self):
print("{} wrote:".format(self.client_address[0]))
data = str(self.request.recv(1024), 'ascii')
print(data)
cur_thread = threading.current_thread()
response = bytes("{}: {}".format(cur_thread.name, data), 'ascii')
self.request.sendall(response)
if __name__ == "__main__":
# Port 0 means to select an arbitrary unused port
HOST, PORT = "localhost", 50007
server = socketserver.ThreadedTCPServer((HOST, PORT), ThreadedTCPRequestHandler)
server.serve_forever()
当您确实必须处理GIL时,对于基本尺寸的服务器来说确实不错。
多处理
运行服务器时,它将被系统识别为一个过程。一个过程又可以运行另一个过程(链的顶部是大多数操作系统的初始过程)。这些通常被称为子过程,而产生它们的过程是父过程。 Python标准图书馆中的multiprocessing module能够管理此类儿童流程。使用此方法将客户端附加到处理过程:
import multiprocessing as mp
import logging
import socket
import time
logger = mp.log_to_stderr(logging.DEBUG)
# https://stackoverflow.com/a/8545724
# With modifications for echo server
def worker(socket):
while True:
client, address = socket.accept()
data = client.recv(1024)
logger.debug("{u} connected".format(u=address))
print(data)
client.sendall(data)
client.close()
if __name__ == '__main__':
num_workers = 20
serversocket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
serversocket.bind(('',9999))
serversocket.listen(20)
workers = [mp.Process(target=worker, args=(serversocket,)) for i in
range(num_workers)]
for p in workers:
p.daemon = True
p.start()
while True:
try:
time.sleep(10)
except:
break
因此,这将创建20个工作过程,这些过程将侦听与主服务器的连接(是的,您可以拥有multiple koude7 calls)。查看过程:
的确,我们看到有21个Python过程,主要的父过程和20个工作过程。现在,这里的问题是,尽管我们已经将工作负载拆分了,但在继续进行下一个工作之前,每个工作流程仍将义务完成客户通信。如果我们可以删除等待客户通信的一些障碍怎么办?
通过选择器进行阻止和投票
事实证明,IO具有阻止和非阻滞的概念。默认情况下,套接字通信正在阻止,这意味着您必须等待工作,例如从连接中接收数据之前要完成的数据。为了解决这个问题,我们可以通过socket.setblocking将插座通信设置为非阻滞。这意味着通常的插座方法将立即返回。不幸的是,这在标准设置中有两个固有的问题:
- 不发送/接收所有客户数据
-
accept()
循环的高CPU用法不断称为
为了解决此问题,有几个呼叫涉及selector模块支持的连接。通过使用DefaultSelector
,选择了最佳的操作系统。例如:
import socket
import selectors
import types
from io import BytesIO
host = "localhost"
port = 50007
def accept_wrapper(sock):
conn, addr = sock.accept()
print('accepted connection from', addr)
conn.setblocking(False)
data = types.SimpleNamespace(addr=addr, inb=b'', outb=BytesIO())
sel.register(conn, selectors.EVENT_READ, data=data)
def service_connection(key, mask):
sock = key.fileobj
data = key.data
if mask & selectors.EVENT_READ:
try:
recv_data = sock.recv(1024)
if recv_data:
data.outb.write(recv_data)
else:
sel.modify(socket, selectors.EVENT_WRITE, data=data)
except:
print('closing connection to', data.addr)
sel.unregister(sock)
sock.close()
if mask & selectors.EVENT_WRITE:
print('writing data to ', data.addr)
sock.sendall(data.outb.getvalue())
print('closing connection to', data.addr)
sel.unregister(sock)
sock.close()
sel = selectors.DefaultSelector()
lsock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
lsock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
lsock.bind((host, port))
lsock.listen()
print('listening on', (host, port))
lsock.setblocking(False)
sel.register(lsock, selectors.EVENT_READ, data=None)
while True:
events = sel.select(timeout=None)
for key, mask in events:
if key.data is None:
accept_wrapper(key.fileobj)
else:
service_connection(key, mask)
现在,幕后选择器可以使用一些有关其做事方式的选项。最后,该过程是:
- 将插座设置为非阻滞
- 块直到客户端连接
- 接受将立即返回客户端套接字
- 将套接字添加到套筒列表中以检查
- 再次,这次我们刚刚接受的客户端插座位于插座列表中,并与服务器套接字一起通知
- 插座准备就绪
- 如果是服务器套接字,请转到3
- 如果是客户连接,请对其进行处理以处理数据
- 主要循环回到5,除了我们没有添加任何新套接字
这几乎是循环进行的。您通常处理的主要方式是:select()
,poll()
和epoll()
。所有这些都有自己的Selector()
实施。使用DefaultSelector
通常选择最佳的。通常,由于它可以检查的1024个插座的极限,因此SELECT并不是最佳性能(尽管它确实在Windows上可以使用)。 poll()
是一个增强版本,同时仍然保持一定的便携式。 select()
和poll()
本质上都保留了插座列表,每次都可以查看和浏览它们。另一方面,epoll()
具有更具反应性的作用,而是可以比select()
和poll()
更有效地处理大量插座。也就是说,它仅在Linux上可用,该Linux限制了可移植性(鉴于这些天来获取Linux服务器是多么容易的问题)。处理large number of connections efficiently通常被称为C10K问题(或K的某些变体)。现在查看代码:
lsock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
lsock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
lsock.bind((host, port))
lsock.listen()
print('listening on', (host, port))
lsock.setblocking(False)
sel.register(lsock, selectors.EVENT_READ, data=None)
在这里,我们有一个普通的插座绑定并聆听服务器。服务器的插座设置为非障碍物,并注册到插座列表中。
while True:
events = sel.select(timeout=None)
for key, mask in events:
if key.data is None:
accept_wrapper(key.fileobj)
else:
service_connection(key, mask)
现在我们有了一个主要事件循环。对于服务器套接字,data
属性设置为无。如果是这种情况,我们运行客户插座接受处理程序。否则,我们正在处理需要处理的现有连接。
def accept_wrapper(sock):
conn, addr = sock.accept()
print('accepted connection from', addr)
conn.setblocking(False)
data = types.SimpleNamespace(addr=addr, inb=b'', outb=BytesIO())
sel.register(conn, selectors.EVENT_READ, data=data)
这将接受我们的连接,并将其设置为非阻滞。接下来的事情是设置一个简单的空间,即nicely explained here。它将连接到插座上,以保持状态在处理时保持状态。这允许读者和作家之间的互动。 outb
设置为BytesIO
类型,在使用字节串联时,它非常具有性能,我们将为跟踪读取的数据而做。
def service_connection(key, mask):
sock = key.fileobj
data = key.data
if mask & selectors.EVENT_READ:
try:
recv_data = sock.recv(1024)
if recv_data:
data.outb.write(recv_data)
else:
sel.modify(socket, selectors.EVENT_WRITE, data=data)
except:
print('closing connection to', data.addr)
sel.unregister(sock)
sock.close()
if mask & selectors.EVENT_WRITE:
print('writing data to ', data.addr)
sock.sendall(data.outb.getvalue())
print('closing connection to', data.addr)
sel.unregister(sock)
sock.close()
现在是有趣的部分。该代码将检查这是读取或写入事件。默认情况下,唯一要检查的是是否准备好插座。完成所有操作后,我们需要回声回去,以便我们切换写作模式。然后,在写作方面,我们只需发送我们收集的所有数据,然后关闭并从我们感兴趣的插座列表中删除插座。epoll()
版本每秒都很好地计算了请求:
Requests per second: 6216.97 [#/sec] (mean)
您可以通过将DefaultSelector
更改为:
-
SelectSelector
(select()) -
PollSelector
(poll()) -
EpollSelector
(epoll())
结论
我会说,这篇文章主要显示不同的服务器类型。如果您真的需要真正的性能,最好考虑为此构建的语言(例如Golang,尤其是因为它强调网络)或具有专门的软件来处理网络通信的所有细微差别。实际上,在大多数情况下,您不需要在现代云计算世界中处理很多事情。负载平衡器,容器化的微服务和许多托管服务为您处理很多。我认为,如果您真的想与一个人一起测试盖子螺纹套接器,我认为这足够好。现在,我们已经看到了不同类型的服务器,下一部分将查看专用类型的服务器:http。