理解Python網絡編程
最近在學習Python網絡編程時看了一些相關的文章,發現大多數要麼講的晦澀難懂,要麼講的比較淺顯,我就想為什麼不在學習的過程中寫一篇心得呢,於是有了這篇文章。我相信技術不全是冰冷的,從人的角度出發,才能更好地領悟編程的樂趣,本文將嘗試以簡潔的文字分享如何理解Python中的網絡編程。
在Python世界裡,喜歡用Python做爬蟲的人不在少數,那麼在請求頁面的過程中發生了什麼呢?
私信小編"資料"即可自動獲取Python學習資料!視頻教程以及各類PDF!
現在編寫一個最簡單的Client/Server程序:
- 首先執行下面的命令開啟一個監聽8000端口的HTTP服務器:
python3 -m http.server 8000
Serving HTTP on 0.0.0.0 port 8000 ...
- 接著編寫一個程序,來對這個服務器發起HTTP請求:
import requests
r = requests.get('http://127.0.0.1:8000/')
print(r)
- 再執行這個程序:
bash-3.2$ python test.py
可以看到,服務器返回了一個200成功響應。
好,現在我們來總結請求過程:
- 客戶端向服務器端發起了一個HTTP(GET)請求。
- 服務器端向客戶端返回了一個HTTP(200)響應。
這是我們能看到的最抽象的過程,下面再用tcpdump細看發生了什麼:
在命令行用tcpdump來監聽本地網卡的tcp連接,
tcpdump -i lo0 port 8000
或者你也可以用-w參數把信息寫出到文件,再通過wireshark來觀察結果:
tcpdump -i lo0 port 8000 -w test.cap
現在執行程序:
bash-3.2$ python test.py
不出意外的話,我們就能觀察到tcpdump輸出類似如下的結果:
通過結果能看到:
- 客戶端發起一個SYN報文,向服務器請求建立一個TCP連接。
- 服務器端返回一個SYN+ACK報文,表示服務器收到了客戶端傳來的請求,並同意與客戶端建立TCP連接。
- 客戶端返回一個ACK報文,表示已經知道服務器同意建立TCP連接,這時候雙方開始通信。
- 客戶端和服務器端不斷地交換信息,接收報文,返回應答。
- 最後數據傳輸完畢,服務器發起一個FIN報文,表示要結束通信,客戶端返回一個ACK應答,接著又發送一個FIN報文,最後服務器端返回一個ACK應答,此時連接過程結束。
仔細一想,這個過程跟現實世界中的“打電話”是非常相似的,與之代替的不就是撥打電話、建立連接、確認應答、交換信息、關閉連接嗎,我們經常說TCP是面向連接的也是這個道理。
現在再來看服務器端的狀態,通過lsof命令來查看綁定8000端口的描述符信息:
lsof -n -i:8000
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
python3.4 1128 tonnie 4u IPv4 0x17036ae156ec58cf 0t0 TCP *:irdmi (LISTEN)
通過結果可以觀察到服務器的進程的一些信息,服務器進程處於LISTEN階段,說明服務器處於保持著監聽連接的狀態:
現在用剛才的例子來解釋TCP中狀態遷移的概念,這時候,如果從客戶端到來一個請求:
- 服務器端接收到客戶端的SYN報文,返回SYN+ACK報文,服務器端進入SYN_RCVD狀態。
- 服務器端收到客戶端返回的ACK應答後,連接建立,進入ESTABLISHED狀態。
- 服務器端的數據傳輸完畢,給客戶端發送FIN報文,進入FIN_WAIT_1狀態。
- 服務器端接收到客戶端返回的ACK應答後,進入FIN_WAIT_2狀態。
- 服務器端接收到客戶端的FIN報文,接著返回一個ACK應答,等待連接關閉,進入 TIME_WAIT狀態。
- 服務器端經過2MSL時間後進入CLOSED狀態,此時連接關閉。
至於客戶端,在每個階段也有各自的狀態,下圖表示了TCP狀態遷移的過程:
下面來看TCP/IP的四層模型:
- 應用層,在這一層上的有HTTP、DNS、FTP、SSH等。
- 傳輸層,在這一層上的有TCP、UDP等。
- 網絡層,在這一層上的有IP、ARP等。
- 網絡接口層,在這一層上的有以太網、PPP等。
在上面的程序中,客戶端與服務器端的通信都要經過這四個層來打交道。那麼這段Python程序是如何操作連接的建立和關閉以及數據的傳輸呢?答案是通過socket提供的一系列方法。
socket是一種IPC方法,它使得同一主機或不同主機的應用程序能交換數據,socket在上圖中處於第三層和第四層之間,所以可以把socket理解為在傳輸層和應用層之間的一組通信接口,或者是一個抽象的通信設備,應用程序藉助socket就能方便地與其他應用程序進行交流。
現在把客戶端的代碼簡化為用socket表現的最簡形式:
import socket
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect(('127.0.0.1', 8000))
sock.send(b'GET / HTTP/1.1\r\nHost: 127.0.0.1:8000\r\n\r\n')
data = sock.recv(4096)
print(data)
sock.close()
是不是感覺跟上面TCP的連接過程十分相似?只是用代碼的方式把這一具現過程給抽象表現出來罷了。
再看服務器端的最簡化代碼:
import socket
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind(('127.0.0.1', 8000))
sock.listen(5)
while 1:
cli_sock, cli_addr = sock.accept()
req = cli_sock.recv(4096)
cli_sock.send(b'hello world')
cli_sock.close()
過程同樣很簡單,總結一下它們的過程:
服務器端:
- 調用socket.socket建立一個socket對象,指定域(domain)和協議(protocol),此時一個文件描述符會綁定到這個socket對象。
- 調用sock.setsockopt設置這個socket選項,本例中把socket.SO_REUSEADDR設置為1,表示服務器端進程終止後,操作系統會為它綁定的端口保留一段時間,以防其他進程在它結束後搶佔這個端口。
- 調用sock.bind為這個socket對象綁定到一個地址上,它需要一個主機地址和端口組成的元組作為參數。
- 調用sock.listen通知系統開始偵聽來自客戶端的連接,參數是在隊列中最大的未決連接數量。
- 調用sock.accept阻塞調用直至返回一個元組,裡面包含了用於與客戶端進行對話的socket對象以及客戶端的地址信息。
- 調用cli_sock.recv方法接受來自客戶端發來的數據,在這個例子中拿到的是b'GET / HTTP/1.1\r\nHost: 127.0.0.1:8000\r\n\r\n'。
- 調用cli_sock.send方法把數據發送給客戶端。
- 調用cli_sock.close結束連接。
客戶端:
- 調用socket.socket建立一個socket對象,指定域(domain)和協議(protocol),此時一個文件描述符會綁定到這個socket對象。
- 調用sock.connect通過指定的主機和端口連接到對端的服務器進程。
- 調用sock.send給服務器端發送數據。
- 調用sock.recv接收服務器端發來的數據。
- 調用sock.close關閉連接。
socket的數據是通過內核維護的讀寫緩衝區來獲取的,如下圖中的表示:
每次從緩衝區寫入或讀入數據都會發起標準的系統調用,如:
int read(fd, buf, bufsize);
int write(fd, buf, bufwrite);
來進行數據的寫或讀。當然對於大文件來說,執行多次read、write等系統調用的耗費是相當可觀的,這時候就要用到sendfile系統調用:
socket的域
在上面的程序中我們建立socket對象都是使用了AF_INET這個參數,它表示這個socket是通過IPV4的方式進行通信的。
這種socket也被叫做Internet Domain Socket,它定義的地址形式是這樣的:
struct in_addr {
in_addr_t s_addr; //32位無符號整數。
};
struct sockaddr_in {
sa_family_t sin_family; //AF_INET
in_port_t sin_port; //端口號
struct in_addr sin_addr; //ipv4地址
unsigned char __pad[X];
};
與之相對的,還有一種socket類型為Unix Domain Socket,它通過AF_UNIX這個參數來創建。它定義的地址形式是這樣的:
struct sockaddr_un {
sa_family_t sun_family; //AF_UNIX
char sun_path[108]; //socket路徑名
};
當用Unix Domain Socket發起bind操作時,會在文件系統中創建一個條目,socket和路徑名為一對一關係。一般來說,Unix Domain Socket只針對在同一主機下應用程序下的網絡通信,它還有一個特點是可以使用目錄權限來控制socket的訪問。(例如我們使用mysql時用到的mysql.sock就是使用unix domain sokcet的載體)
socket的協議
在protocol上我們使用了SOCK_STREAM,表示這是個流式套接字(即TCP),除此之外我們還可以把它指定為SOCK_DGRAM,表示這是個數據報套接字(即UDP)。
TCP跟UDP的一些基本區別:
- TCP面向連接,UDP不面向連接。
- TCP面向字節,不存在消息邊界,可能存在粘包問題。UDP則面向報文。
- TCP會盡力保證數據的可靠交付,而UDP默認不做保證。
- TCP頭部20字節,UDP頭部8字節。
socket的通道
一般來說,socket的信道是雙向的,即一個socket既能讀又能寫。有時候你需要建立一個半開放的socket,這時候就要使用socket的shutdown調用,它接收一個標記,其中:
- SHUT_RD代表關閉連接的讀端。
- SHUT_WR代表關閉連接的寫端。
- SHUT_RDWR代表關閉連接的讀端跟寫端。
shutdown()不會顯式關閉文件描述符,需要另外調用close()。
現在你應該對socket有一個大致的瞭解了,現在我們再來探討一個socket服務器是怎麼編寫的。
再回到最開始的那段代碼:
python3 -m http.server 8000
Serving HTTP on 0.0.0.0 port 8000 ...
我們直接用python內置的HTTPServer綁定了8000這個端口上。
查看python3的http.server所在的源碼:
def test(HandlerClass=BaseHTTPRequestHandler,
ServerClass=HTTPServer, protocol="HTTP/1.0", port=8000, bind=""):
server_address = (bind, port)
HandlerClass.protocol_version = protocol
httpd = ServerClass(server_address, HandlerClass)
sa = httpd.socket.getsockname()
print("Serving HTTP on", sa[0], "port", sa[1], "...")
try:
httpd.serve_forever()
except KeyboardInterrupt:
print("\nKeyboard interrupt received, exiting.")
httpd.server_close()
sys.exit(0)
當http.server以模塊方式運行時會調用test方法,創建一個測試服務器,這個服務器默認使用了HTTPServer作為服務器的類,
BaseHTTPRequestHandler作為請求的處理類。看HTTPServer,也就是我們一開始使用的服務器:
class HTTPServer(socketserver.TCPServer):
allow_reuse_address = 1
def server_bind(self):
socketserver.TCPServer.server_bind(self)
host, port = self.socket.getsockname()[:2]
self.server_name = socket.getfqdn(host)
self.server_port = port
它繼承了socketserver.TCPServer這個類,找到socketserver所在的源碼,發現有一段註釋,說明了幾個服務器類之間的關係。
+------------+
| BaseServer |
+------------+
|
v
+-----------+ +------------------+
| TCPServer |------->| UnixStreamServer |
+-----------+ +------------------+
|
v
+-----------+ +--------------------+
| UDPServer |------->| UnixDatagramServer |
+-----------+ +--------------------+
可以看到,TCPServer繼承自BaseServer,而UDPServer又繼承自TCPServer。
找到TCPServer這個類,可以看到它默認使用socket.AF_INET(IPV4)和socket.SOCK_STREAM(TCP)協議,並會在初始化的時候建立一個socket對象,注意這時候這個socket對象僅僅只是被創建處理,它還沒有做任何的綁定。
class TCPServer(BaseServer):
address_family = socket.AF_INET
socket_type = socket.SOCK_STREAM
request_queue_size = 5
allow_reuse_address = False
def __init__(self, server_address, RequestHandlerClass, bind_and_activate=True):
BaseServer.__init__(self, server_address, RequestHandlerClass)
self.socket = socket.socket(self.address_family,
self.socket_type)
if bind_and_activate:
try:
self.server_bind()
self.server_activate()
except:
self.server_close()
raise
真正的綁定操作發生在self.server_bind()這行代碼裡,現在我們查看這個方法,它把socket對象綁定到__init__初始化中得到的地址上,並獲取服務端的地址:
def server_bind(self):
if self.allow_reuse_address:
self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self.socket.bind(self.server_address)
self.server_address = self.socket.getsockname()
綁定後的監聽動作則發生在self.server_activate()這行裡,它緊跟著binding後進行,在這個方法裡socket會在綁定的地址上監聽到來的連接。
def server_activate(self):
self.socket.listen(self.request_queue_size)
現在我們關心的是,如果現在有一個客戶端發起了連接請求,服務器類會怎麼處理呢?我們可以在TCPServer繼承的BaseServer找到答案。
找到BaseServer的serve_forever方法:
def serve_forever(self, poll_interval=0.5):
self.__is_shut_down.clear()
try:
while not self.__shutdown_request:
r, w, e = _eintr_retry(select.select, [self], [], [],
poll_interval)
if self in r:
self._handle_request_noblock()
self.service_actions()
finally:
self.__shutdown_request = False
self.__is_shut_down.set()
當服務器沒被shutdown時,就會在while循環中用select去輪詢活躍的socket,返回活躍的文件描述符,當檢測到當前有可讀事件時,就會調用_handle_request_noblock方法來處理socket:
def get_request(self):
return self.socket.accept()
def _handle_request_noblock(self):
try:
request, client_address = self.get_request()
except OSError:
return
if self.verify_request(request, client_address):
try:
self.process_request(request, client_address)
except:
self.handle_error(request, client_address)
self.shutdown_request(request)
在_handle_request_noblock方法中,服務器拿到可讀的socket(request),調用process_request方法來處理請求,當發生異常時調用handle_error處理錯誤,接著調用shutdown_request關閉請求。
def process_request(self, request, client_address):
self.finish_request(request, client_address)
self.shutdown_request(request)
def finish_request(self, request, client_address):
self.RequestHandlerClass(request, client_address, self)
def shutdown_request(self, request):
self.close_request(request)
最後來看process_request方法做了什麼事情,首先它調用finish_request方法,實例化出一個RequestHandlerClass(請求處理類)來處理本次請求,處理完成後調用shutdown_request方法來結束請求。
看看UDPServer,幾乎是換湯不換藥,只修改了TCPServer的幾個重要的參數:
class UDPServer(TCPServer):
allow_reuse_address = False
socket_type = socket.SOCK_DGRAM
max_packet_size = 8192
def get_request(self):
data, client_addr = self.socket.recvfrom(self.max_packet_size)
return (data, self.socket), client_addr
服務器類差不多就這樣了,再來看RequestHandler。
先看最原始的BaseRequestHandler類:
class BaseRequestHandler:
def __init__(self, request, client_address, server):
self.request = request
self.client_address = client_address
self.server = server
self.setup()
try:
self.handle()
finally:
self.finish()
它接收一個請求(socket)作為參數,調用self.setup()建立用於讀寫的文件描述符,接著調用self.handle()來處理這次請求,最終調用self.finish()結束處理。
現在看StreamRequestHandler類:
class StreamRequestHandler(BaseRequestHandler):
rbufsize = -1
wbufsize = 0
timeout = None
disable_nagle_algorithm = False
def setup(self):
self.connection = self.request
if self.timeout is not None:
self.connection.settimeout(self.timeout)
if self.disable_nagle_algorithm:
self.connection.setsockopt(socket.IPPROTO_TCP,
socket.TCP_NODELAY, True)
self.rfile = self.connection.makefile('rb', self.rbufsize)
self.wfile = self.connection.makefile('wb', self.wbufsize)
def finish(self):
if not self.wfile.closed:
try:
self.wfile.flush()
except socket.error:
pass
self.wfile.close()
self.rfile.close()
在setup過程為socket建立了一個用於讀的文件描述符以及一個用於寫的文件描述符,在finish的過程中會把寫緩衝區刷新,關閉讀寫兩個文件描述符。
從上面得知handle是處理請求的核心過程,在BaseHTTPRequestHandler中是這樣實現的,handler會處理一個socket請求,如果該請求是斷續請求而且沒有超時或異常的話,就會繼續處理下一個請求(例如keep-alive、大數據傳輸):
class BaseHTTPRequestHandler(socketserver.StreamRequestHandler):
def handle(self):
self.handle_one_request()
while not self.close_connection:
self.handle_one_request()
其他部分太瑣碎就不貼了,完成這一步後,服務器端就完成了一個來自客戶端的請求的處理。
有的人還是可能覺得BaseHTTPRequestHandler和SimpleHTTPRequestHandler這類的處理類太挫太不靈活了,針對這個http.server模塊還提供了一種處理類:CGIHTTPRequestHandler,它可以通過請求信息選擇執行指向的cgi腳本。cgi雖然更靈活,但也有一些弊端,於是後面又有了各種方案:fastcgi、mod_python、wsgi...有興趣的可以看 HOWTO Use Python in the web。但在不復雜的情況下,這些自帶的請求處理類也勉強夠用了。
再談到之前說的HTTPServer,在線上環境中一般沒有人會這麼傻,直接使用這個內置的HTTPServer的。因為它是單進程而且在請求的生命週期內都只能處理同一個請求,不過好在socketserver這個模塊也提供了ThreadingMixIn以及ForkingMixIn,他們的目的是當一個請求到來時使用新建一個線程或一個進程去處理它。
使用方法十分簡單,用ThreadingMixIn或ForkingMixIn與Server類組成混合類就行了:
class ThreadingHTTPServer(ThreadingMixIn, HTTPServer):
pass
通過ThreadingMixIn的源碼確實可以看到它重寫了process_request這個方法,它會覆蓋混合類中Server類的process_request方法,當Server處理請求時就會調用到這個方法,在ThreadingMixIn的處理中,會新起一個線程來處理請求。這樣一來,服務器的併發能力就比原來有了很大的提升了。
class ThreadingMixIn:
daemon_threads = False
def process_request_thread(self, request, client_address):
try:
self.finish_request(request, client_address)
self.shutdown_request(request)
except:
self.handle_error(request, client_address)
self.shutdown_request(request)
def process_request(self, request, client_address):
t = threading.Thread(target = self.process_request_thread,
args = (request, client_address))
t.daemon = self.daemon_threads
t.start()
但有的人看到這裡不一定會滿意,一個請求一個線程,一百個請求一百個線程,一萬個、十萬個...還不得上天啊。在實際環境中,一般需要把線程控制在一定的數量內(例如線程池)以降低系統負載。
現在繼續把目光轉移到我們一開始討論的socket上,再來扯IO模型的問題。
我們知道socket的輸入需要兩個階段:
- 等待數據準備好。
- 從內核向進程複製數據。
因為等待的過程是阻塞式,所以我們上面使用多線程就是降低這個阻塞所帶來的影響。
現在來看五種IO模型:
阻塞IO模型
recv->無數據報準備好->等待數據->數據報準備好->數據從內核複製到用戶空間->複製完成->返回成功指示
非阻塞IO模型
recv->無數據報準備好->返回EWOULDBLOCK->recv->無數據報準備好->返回EWOULDBLOCK->數據報準備好->數據從內核複製到用戶空間->複製完成->返回成功指示
特點:輪詢操作,大量佔用cpu時間。
IO複用模型
select->無數據報準備好->據報準備好->返回可讀條件->recv->數據從內核複製到用戶空間->複製完成->返回成功指示
信號驅動模型
建立信號處理程序(sigaction)->遞交SIGIO->recv->數據從內核複製到用戶空間->複製完成->返回成功指示
異步IO模型
aio_read->無數據準備好->數據報準備好->數據從內核複製到用戶空間->複製完成->遞交aio_read中指定的信號
特點:直到數據複製完成產生信號的過程中進程都不被阻塞。
毫無疑問,我們從開始一直使用著阻塞的IO模型,這個效率是低下的。
為了獲取更好的性能,我們一般採用IO多路複用模型,例如select和poll操作,運行進程同時檢查多個文件描述符以找出它們任意一個是否可以進行IO操作,內核一旦發現進程指定的一個或多個IO條件就緒(輸入準備被讀取,或描述符能承接更多的輸出),它就通知進程。
但前面說了select和poll有一個弊端就是他們在檢查可用描述符的時候都是不斷地遍歷又遍歷,當要監聽的socket的文件描述符數量龐大時,性能會急劇下降,CPU消耗嚴重。
信號驅動模型比他們優越的地方在於,當有輸入數據來到指定的文件描述符時,內核向請求數據的進程發送一個信號,進程可以處理其他任務,通過接收信號以獲得通知。
而epoll則更進一步,用事件驅動的方式來監聽fd,避免了信號處理的繁瑣,在文件描述符上註冊事件函數,由系統監視這些文件描述符,當在文件描述符可就緒時,內核通知應用進程。
在一些高併發的網絡操作上,epoll的性能通常比select跟poll好幾個數量級。
IO調用中有兩個概念:
- 水平觸發:如果文件描述符可以非阻塞地進行io調用,此時認為他已經就緒)。(支持模型:select,poll,epoll等)
- 邊緣觸發:如果文件描述符自上次來的時候有了新的io活動(新的輸入),觸發通知。(支持模型:信號驅動,epoll等)
在實際開發中要注意他們的區別,知道邊緣觸發為什麼可能產生socket飢餓問題,怎麼解決。
用一張圖總結5個IO模型是這樣的:
使用多路IO複用模型能有效提高網絡編程的質量。
HTTP
現在再來看HTTP,HTTP是在TCP之上的無狀態的協議,處於四層模型中的應用層,HTTP使用TCP來傳輸報文數據。
以瀏覽器輸入一個網址打開為例,看HTTP的請求過程:
- 瀏覽器首先從URL中解析出主機名,端口等信息,URL的通用格式為:
:// 。: @ : / ; ? # - 瀏覽器把主機名轉換為IP地址(DNS)。
- 瀏覽器與服務器建立一條TCP連接。
- 瀏覽器在TCP連接上發送一條HTTP請求報文。
- 服務器在TCP連接上返回一條HTTP響應報文。
- 關閉連接,瀏覽器渲染文檔。
HTTP的請求信息包括幾個要素:
- 請求行,例如GET /index.html HTTP/1.1,表示要請求index.html這個文件。
- 請求頭(首部)。
- 空行。
- 消息體。
例如在第一個例子中,我們向8000端口發起請求:
GET / HTTP/1.1 (請求行)
Host: 127.0.0.1:8000 (請求頭)
會得到以下回應:
HTTP/1.0 200 OK (響應行)
Content-Length: 5252
Content-type: text/html; charset=utf-8
Date: Tue, 21 Feb 2017 08:36:01 GMT
Server: SimpleHTTP/0.6 Python/3.4.5Directory listing for / Directory listing for /
....
HTTP的關鍵之處在於它的首部,HTTP的首部信息決定了客戶端和服務器端能做什麼事情。
HTTP狀態碼
- 1xx消息——請求已被服務器接收,繼續處理
- 2xx成功——請求已成功被服務器接收、理解、並接受
- 3xx重定向——需要後續操作才能完成這一請求
- 4xx請求錯誤——請求含有詞法錯誤或者無法被執行
- 5xx服務器錯誤——服務器在處理某個正確請求時發生錯誤
HTTP & DOM
DOM,又稱Document Object Module,即文檔對象模型。我們在寫爬蟲的時候通常都需要對html頁面進行解析,這時候就需要dom解析器來對抓取的頁面進行分析。
關於網絡編程,這裡只是冰山一角,還有很多可以說的,鑑於本人水平不足,有興趣的讀者可以去自行了解。
閱讀更多 python柚子 的文章