1. 程式人生 > >啊,我的程序為啥卡住啦

啊,我的程序為啥卡住啦

docs lap lsof 恢復 kill 循環 裏的 transfer 總結

  服務器程序員最怕的就是程序crash,不過有時候程序沒有crash,但是“不工作”了也是夠嚇人的。所謂“不工作”就是指程序不再響應新的請求,處在了某種自娛自樂的狀態,英語有一個很形象但的單詞“hung”,但我不知道怎麽翻譯,姑且稱之為“卡住”吧。本人遇到過的有兩種情況,一種是卡在系統調用,如常見的磁盤IO或者網絡、多線程鎖;另一種就是代碼進入了死循環。

  在《日誌的藝術》一文中,討論了日誌的重要性,如果日誌恰當,也能幫助我們分許程序卡住的問題。如果狂刷重復的日誌,那麽很可能就是死循環,從日誌內容就能分析出死循環的位置,甚至是死循環的原因。如果沒有日誌輸出了,那麽看看最後一條日誌的內容,也許就會告訴我們即將進行IO操作,當然也可能是即將進入死循環。

  如果日誌無法提供充足的信息,那就得求助於其他的手段,在Linux下當仁不讓的自然是gdb,gdb的功能很強大,陳皓大牛的用GDB調試程序系列是我見過的講解gdb最好的中文文章。CPython是用C語言實現的,自然也是可以用gdb來調試的,只不過,默認只顯示C棧,凡人如我是無法腦補出python棧來的,這個時候就需要使用到python-dbg與libpython了,前者幫助顯示Python源碼的符號信息,後者能讓我們能在Python語言這個層面調試程序,比如打印Python調用棧。

  本文簡答介紹在linux環境下如何利用gdb來分析卡住的程序,本文使用的Python為Cpython2.7,操作系統為Debian。

  本文地址:http://www.cnblogs.com/xybaby/p/8025435.html

阻塞在IO

  程序被卡住,很可能是程序被阻塞了,即在等待(wait)等個系統調用的結束,比如磁盤IO與網絡IO、多線程,默認的情況下很多系統調用都是阻塞的。多線程的問題復雜一下,後面專門介紹。下面舉一個UDP Socket的例子(run_forever_block.py):

 1 # -*- coding: utf-8 -*-
 2 import socket
 3 
 4 def simple_server():
 5         address = (0.0.0.0, 40000)
6 s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 7 s.bind(address) 8 9 while True: 10 data, addr = s.recvfrom(2048) 11 if not data: 12 print "client has exist" 13 break 14 print "received:", data, "from", addr 15 16 if __name__ == __main__: 17 simple_server()

  這是一個簡單的UDP程序,代碼在(0.0.0.0, 40000)這個地址上等待接收數據,核心就是第10行的recvfrom函數,這就是一個阻塞(blocking)的系統調用,只有當讀到數據之後才會返回,因此程序會卡在第10行。當然,也可以通過fcntl設置該函數為非阻塞(non-blocking)。

  我們來看看阻塞的情況,運行程序,然後通過top查看這個進程的狀態

  技術分享圖片

  可以看到這個進程的pid是26466,進程狀態為S(Sleep),CPU為0.0。進程狀態和CPU都暗示我們,當前進程正阻塞在某個系統調用。這個時候,有一個很好使的命令:strace,可以跟蹤進程的所有系統調用,我們來看看

~$ strace -T -tt -e trace=all -p 26466
Process 26466 attached
19:21:34.746019 recvfrom(3,

  可以看到,進程卡在了recvfrom這個系統調用,對應的文件描述符(file descriptor)是3,其實通過recvfrom這個名字,大致也能定位問題。關於文件描述符,還有一個更實用的命令,lsof(list open file),可以看到當前進程打開的所有文件,在linux下,一切皆文件,自然也就包括了socket。

lsof -p 26466
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME

...
python 26466 xxxxxxx 3u IPv4 221871586 0t0 UDP *:40000

  從上面可以看出這個文件描述符(3U)更詳細的信息,比如是IPV4 UDP,監聽在40000端口等等。

使用gdb調試Python程序

  上面這個例子非常的簡單,簡單到我們直接從系統調用就能看出問題,但是實際的情況下可能更加復雜,即無法通過系統調用直接定位到處問題的代碼。這個時候就可以使用gdb了,關於如何用gdb來調試python程序,可以參考了使用gdb調試Python進程這篇文章。以上面的代碼為例,首先得做好準備條件

  首先,參考DebuggingWithGdb,根據自己的Linux系統安裝好python-dbg,在我的平臺(Debian)上即 sudo apt-get install gdb python2.7-dbg

  然後,下載libpython,放在自己的HOME目錄下

  接下來就可以使用gdb進行分析了:gdb -p 26466

  在gdb的交互式環境中,使用bt命令,看到的是C棧,意義不大,我們直接來看Python棧

(gdb) python
>import sys
>sys.path.insert(0, ‘/home/xxx‘)
>import libpython
>end


(gdb) py-bt

Traceback (most recent call first):
  File "run_forever_block.py", line 10, in simple_server
    data, addr = s.recvfrom(2048)
  File "run_forever_block.py", line 17, in <module>
    simple_server()

  可以看到,通過py-bt就能顯示出完整的python調用棧,能幫助我們定位問題。還有很多py開頭的命令,具體可見libpython.py中(所有gdb.Command的子類都是一個命令),這篇文章中總結了幾個常用的:

  • py-list   查看當前python應用程序上下文
  • py-bt    查看當前python應用程序調用堆棧
  • py-bt-full    查看當前python應用程序調用堆棧,並且顯示每個frame的詳細情況
  • py-print    查看python變量
  • py-locals   查看當前的scope的變量
  • py-up    查看上一個frame
  • py-down    查看下一個frame

死循環

  死循環很令人討厭,死循環是預期之外的無限循環。最典型的預期之內的無限循環是socketserver,進程不死,服務不止。而死循環看起來很忙(CPU100%),但是沒有任何實質的作用。死循環有不同的粒度,最粗的粒度是兩個進程之間的相互調用,比如RPC;其次是函數級別,較為常見的是沒有邊界條件的遞歸調用,或者在多個函數之間的相互調用;最小的粒度是在一個函數內部,某一個代碼塊(code block)的死循環,最為常見的就是for,while語句。在Python中,函數級別是不會造成無限循環的,如代碼所示:

1 # -*- coding: utf-8 -*-
2 def func1():
3     func()
4 
5 def func():
6     func1()
7 
8 if __name__ == __main__:
9     func()

  運行代碼,很快就會拋出一個異常:RuntimeError: maximum recursion depth exceeded。顯然,python內部維護了一個調用棧,限制了最大的遞歸深度,默認約為1000層,也可以通過sys.setrecursionlimit(limit)來修改最大遞歸深度。在Python中,雖然出現這種函數級別的死循環不會導致無限循環,但是也會占用寶貴的CPU,也是決不應該出現的。

  而代碼塊級別的死循環,則會讓CPU轉到飛起,如下面的代碼

1 # -*- coding: utf-8 -*-
2 
3 def func():
4     while True:
5         pass
6 
7 if __name__ == __main__:
8     func()

  這種情況,通過看CPU還是很好定位的

  技術分享圖片

  從進程狀態R(run),以及100%的CPU,基本上就能確定是死循環了,當然也不排除是CPU密集型,這個跟代碼的具體邏輯相關。這個時候,也是可以通過gdb來看看當前調用棧的,具體的準備工作如上,這裏直接給出py-bt結果

(gdb) py-bt
Traceback (most recent call first):
  File "run_forever.py", line 5, in func
  File "run_forever.py", line 8, in <module>
(gdb)

  在《無限“遞歸”的python程序》一文中,提到過使用協程greenlet能產生無限循環的效果,而且看起來非常像函數遞歸,其表現和gdb調試結果與這裏的死循環是一樣的

多線程死鎖

  由於Python的GIL,在我們的項目中使用Python多線程的時候並不多。不過多線程死鎖是一個非常普遍的問題,而且一般來說又是一個低概率的事情,復現不容易,多出現在高並發的線上環境。這裏直接使用《飄逸的python - 演示一種死鎖的產生》中的代碼,然後分析這個死鎖的多線程程序

技術分享圖片
 1 #coding=utf-8
 2 import time
 3 import threading
 4 class Account:
 5     def __init__(self, _id, balance, lock):
 6         self.id = _id
 7         self.balance = balance
 8         self.lock = lock
 9 
10     def withdraw(self, amount):
11         self.balance -= amount
12 
13     def deposit(self, amount):
14         self.balance += amount
15 
16 
17 def transfer(_from, to, amount):
18     if _from.lock.acquire():#鎖住自己的賬戶
19         _from.withdraw(amount)
20         time.sleep(1)#讓交易時間變長,2個交易線程時間上重疊,有足夠時間來產生死鎖
21         print wait for lock...
22         if to.lock.acquire():#鎖住對方的賬戶
23             to.deposit(amount)
24             to.lock.release()
25         _from.lock.release()
26     print finish...
27 
28 a = Account(a,1000, threading.Lock())
29 b = Account(b,1000, threading.Lock())
30 threading.Thread(target = transfer, args = (a, b, 100)).start()
31 threading.Thread(target = transfer, args = (b, a, 200)).start()
線程死鎖代碼實例

  運行代碼,可以看見,該進程(進程編號26143)也是出於Sleep狀態,因為本質上進程也是阻塞在了某個系統調用,因此,同樣可以使用strace

p$ strace -T -tt -e trace=all -p 26143
Process 26143 attached
19:29:29.289042 futex(0x1286060, FUTEX_WAIT_PRIVATE, 0, NULL

  可以看見,進程阻塞在futex這個系統調用,參數的意義可以參見manpage。

  gdb也非常適用於調試多線程程序,對於多線程,有幾個很使用的命名

  •   info thread:  列出所有的線程,以及所在線程
  •   thread x:  切換到第X號線程
  •   thread apply all bt:  打印所有線程的調用棧

  下面是簡化後的結果

(gdb) info thread
Id Target Id Frame
3 Thread 0x7fb6b2119700 (LWP 26144) "python" 0x00007fb6b38d8050 in sem_wait () from /lib/x86_64-linux-gnu/libpthread.so.0
2 Thread 0x7fb6b1918700 (LWP 26145) "python" 0x00007fb6b38d8050 in sem_wait () from /lib/x86_64-linux-gnu/libpthread.so.0
* 1 Thread 0x7fb6b3cf8700 (LWP 26143) "python" 0x00007fb6b38d8050 in sem_wait () from /lib/x86_64-linux-gnu/libpthread.so.0
(gdb) thread apply all bt

Thread 3 (Thread 0x7fb6b2119700 (LWP 26144)):
#0 0x00007fb6b38d8050 in sem_wait () from /lib/x86_64-linux-gnu/libpthread.so.0
#1 0x000000000057bd20 in PyThread_acquire_lock (waitflag=1, lock=0x126c2f0) at ../Python/thread_pthread.h:324
#2 lock_PyThread_acquire_lock.lto_priv.2551 () at ../Modules/threadmodule.c:52
...

Thread 2 (Thread 0x7fb6b1918700 (LWP 26145)):
#0 0x00007fb6b38d8050 in sem_wait () from /lib/x86_64-linux-gnu/libpthread.so.0
#1 0x000000000057bd20 in PyThread_acquire_lock (waitflag=1, lock=0x131b3d0) at ../Python/thread_pthread.h:324
...

Thread 1 (Thread 0x7fb6b3cf8700 (LWP 26143)):
#0 0x00007fb6b38d8050 in sem_wait () from /lib/x86_64-linux-gnu/libpthread.so.0
#1 0x000000000057bd20 in PyThread_acquire_lock (waitflag=1, lock=0x1286060) at ../Python/thread_pthread.h:324
#2 lock_PyThread_acquire_lock.lto_priv.2551 () at ../Modules/threadmodule.c:52
...

  這裏推薦一篇非常不錯的文章,用gdb調試python多線程代碼-記一次死鎖的發現,記錄了一個在真實環境中遇到的多線程死鎖問題,感興趣的同學可以細讀。

Coredump

  當進程被卡主,我們需要盡快恢復服務,被卡主的原因可能是偶然的,也有可能是必然的,而且實際中導致進程卡主的真正原因不一定那麽清晰明了。在我們分析卡主的具體原因的時候,我們可能需要先盡快重啟服務,這個時候就需要保留現場了,那就是coredump。

  按照我的經驗,有兩種方式。

  第一種,kill -11 pid,11代表信號SIGSEGV,在kill這個進程的同時產生coredump,這樣就可以迅速重啟程序,然後慢慢分析

  第二種,在gdb中使用gcore(generate-core-file),這個也很有用,一些bug我們是可以通過gdb線上修復的,但問題還是需要時候繼續查看,這個時候就可以用這個命令先產生coredump,然後退出gdb,讓修復後的程序繼續執行。

總結

  當一個進程不再響應新的請求時,首先看日誌,很多時候日誌裏面會包含足夠的信息。

  其次,看進程的信息,Sleep狀態,以及100%的CPU都能給我們很多信息,也可以用pidstat查看進程的各種信息

  如果懷疑進程被阻塞了,那麽可以使用strace確認

  linux下,gdb是很好的調試武器,建議平時多試試,coredump也是一定會遇到的

  linux下運行的Python程序,可以配合使用python-dbg和libpython分析程序。

references

DebuggingWithGdb

GDB: The GNU Project Debugger

libpython.py

EasierPythonDebugging

用GDB調試程序

使用gdb調試Python進程

用gdb調試python多線程代碼-記一次死鎖的發現

啊,我的程序為啥卡住啦