python多進程多線程時使用uwsgi與fork的坑

故事背景

這段時間在做一個nginx + uwsgi + python的項目,有個需求是需要在服務運行過程中可以改變配置並生效,可以理解爲熱重載. 之前這些配置都是寫死在項目的配置文件中的基礎配置,一般就是python項目中的config.py文件. 現在配置變更使用了開源的apollo作爲管理端,需要python使用client對接apollo.

先看一份常見的python後臺使用uwsgi的配置:

test@python:~/app$ cat uwsgi.ini
[uwsgi]
module = app
wsgi-file = app.py
master = true
processes = 4           # 多個work進程
enable-threads = true   # 允許啓動多線程
#lazy-apps = true       # 後面再說
http = :3000
die-on-term = true
pidfile = ./uwsgi.pid
chdir = /home/test/app
disable-logging = true
log-maxsize = 5000000
daemonize = /home/test/app/log.log

這裏給出python代碼的demo app.py:

from flask import Flask, jsonify, request
from apollo import Config

cf = Config("test", "application")
print("----------key-----------")
print(cf.SQLALCHEMY_TRACK_MODIFICATIONS)    # 嘗試獲取一些配置
print(cf.LOG_NAME)
print("----------key-----------")

app = Flask(__name__)


@app.route('/')
def hello_world():
    key = request.values.get('key')
    new = getattr(cf, key)
    # 嘗試實時獲取配置
    return jsonify({'data': new, 'apo': cf.apo.get_value(key), "my": cf.SQLALCHEMY_POOL_SIZE})


application = app  # for uwsgi.ini
if __name__ == "__main__":
    app.run(port=5000)

再看看這個配置啓動後的效果:

test@python:~/app$ ps -ef|grep uwsgi.ini
test      16224     1  0 14:36 ?        00:00:00 uwsgi --ini uwsgi.ini
test      16225 16224  0 14:36 ?        00:00:00 uwsgi --ini uwsgi.ini
test      16226 16224  0 14:36 ?        00:00:00 uwsgi --ini uwsgi.ini
test      16227 16224  0 14:36 ?        00:00:00 uwsgi --ini uwsgi.ini
test      16228 16224  0 14:36 ?        00:00:00 uwsgi --ini uwsgi.ini
test      16229 16224  0 14:36 ?        00:00:00 uwsgi --ini uwsgi.ini
test      16378 15998  0 14:39 pts/48   00:00:00 grep --color=auto uwsgi.ini

然後問題來了

每次在apollo後臺變更配置時明明配置的localfile本地文件已經變更但是進程中的cache就是沒變...查看了apollo開源說明中推薦的三種python client,發現實現方式都是大同小異,主要就是啓動守護線程長鏈接pull服務端的接口,服務端有變更時接口就能訪問通,進而觸發這個守護線程的動作去更新cache和localfile,上面說了localfile已經有了更新的動作爲啥cache沒被更新呢? 帶着疑問去看了這三個開源庫的issues,然後發現uwsgi+django項目中配置的apollo, 不能獲取最新apollo數據 嗯,看來是通病了...

驗證猜想

翻了下其他語言上沒啥類似問題,那會不會是python的特色,先來個手動多進程試試:

1. 執行python app.py
2. 修改app.py中的端口號
3. 執行python app.py
4. 重複2,3
5. 注意看打印的日誌
6. 試着訪問下設置的端口 curl "127.0.0.1:3000"
7. 修改apollo的配置
8. 看看日誌,再執行curl "127.0.0.1:3000",看看獲取的配置是不是最新的.

然後發現沒啥問題啊,每個實例都能訪問到最新的,日誌中都打印了更新cache和localfile的日誌.那麼就排除了python的問題,聚焦到uwsgi的配置上看看吧,網上搜的話比較凌亂,一般搜官方文檔好了,如這裏Python/WSGI應用快速入門,然後就會看到左邊有個關於Python線程的注意事項嗯,難道是我沒加enable-threads = true導致的? 立馬加上試試,效果還是不行,那繼續看文檔吧,翻看目錄直到看到這句 優雅重載的藝術,下面摘抄文檔中的一些關鍵語句:

Preforking VS lazy-apps VS lazy

這是uWSGI項目具有爭議的選擇之一。

默認情況下,uWSGI在第一個進程中加載整個應用,然後在加載完應用之後,會多次 fork() 自己。這是常見的Unix模式,它可能會大大減少應用的內存使用,允許很多好玩的技巧,而在一些語言上,可能會讓帶給你很多煩惱。

儘管它的名聲如此,但是uWSGI是作爲一個Perl應用服務器 (它不叫做 uWSGI,並且它也並不開源) 誕生的,而在Perl的世界裏,preforking一般是一種受到祝福的方式。

然而,對於許多其他的語言、平臺和框架來說,這並不是真的,因此,在開始處理uWSGI之前,你應該選擇在你的棧中如何管理 fork() 。

而從“優雅重載”的角度來看,preforking極大的提高了速度:只加載你的應用一次,而生成額外的worker將會非常快。避免棧中的每個worker都訪問磁盤會降低啓動時間,特別是對於那些花費大量時間訪問磁盤以查找模塊的框架或者語言。

不幸的是,每當你的修改代碼時,preforking方法迫使你重載整個棧,而不是隻重載worker。

除此之外,你的應用可能需要preforking,或者由於其開發的方式,可能完全因其崩潰。

取而代之的是,lazy-apps模式會每個worker加載你的應用一次。它將需要大約O(n)次加載 (其中,n是worker數),非常有可能會消耗更多內存,但會運行在一個更加一致乾淨的環境中。

記住:lazy-apps與lazy不同,前者只是指示 uWSGI對於每個worker加載應用一次,而後者更具侵略性些 (一般不提倡),因爲它改變了大量的內部默認行爲。

看來是默認配置導致了多進程多線程情況下,uwsgi加載完後第一個完整的work後,剩下processes中配置的work都是通過fork來的,看看uwsgi的啓動日誌也會發現的確只加載了一個app,每次操作也只有一個守護線程在監聽和打印日誌,那爲啥fork來就不是完整的服務了呢,這就要說到unix fork的原理和實現了.

在unix/linux操作系統中,提供了一個fork()系統函數,它有這些特性:

0. fork()函數用於從一個已經存在的進程內創建一個新的進程,新的進程稱爲“子進程”,相應地稱創建子進程的進程爲“父進程”。使用fork()函數得到的子進程是父進程的複製品,子進程完全複製了父進程的資源,包括進程上下文、代碼區、數據區、堆區、棧區、內存信息、打開文件的文件描述符、信號處理函數、進程優先級、進程組號、當前工作目錄、根目錄、資源限制和控制終端等信息,而子進程與父進程的區別有進程號、資源使用情況和計時器等。

1. 普通的函數調用,調用一次,返回一次,但是fork()調用一次,返回兩次。因爲操作系統自動把當前進程(父進程)複製了一份(子進程),然後分別在父進程和子進程內返回。

2. 子進程永遠返回0,父進程返回子進程的ID。

3. 一個父進程可以fork()出很多個子進程。因此,父進程要記下每個子進程的ID,而子進程只需要調用getppid()就可以拿到父進程的id。getpid()可以拿到當前進程id

4. 父進程、子進程執行順序沒有規律,完全取決於操作系統的調度算法。

5. 如果父進程有多個線程會不會複製父進程的多個線程呢?其實子進程創建出來時只有一個線程,就是調用fork()函數的那個線程。

也就是說 uwsgi fork進程(不區分進程和線程)的時候只會把當前正在執行的app線程複製一份,而不會把隨app線程初始化過程中產生的守護線程apollo-client也fork一份,那麼解決起來就簡單了,配置下lazy-apps = true就可以了,每次fork都是一個真正完整的app進程包含了app線程和apollo-client線程.如果我還沒說清楚的話,可以參考這裏
謹慎使用多線程中的forkfork多線程進程時的坑(轉)

那麼自然就想到既然cache是每個進程獨立的,那就乾脆去掉cache使用localfile,也很簡單粗暴是可以完成多進程共享配置的功能,每次訪問配置都做下文件IO操作,這裏不是什麼訪問量大的服務的話可以這麼操作,下面再說說其他方案.

使用緩存

重構apollo client中線程中的cache緩存的存儲方式,比如切換爲redis,同樣是IO操作比每次都http直接查詢apollo配置接口要好些,要是是遠程redis-server那網絡延時也不可忽略,進而考慮本地redis或者使用uWSGI緩存框架

使用緩存API,在應用中訪問緩存
你可以通過使用緩存API,訪問你的實例或者遠程實例中的各種緩存。目前,公開了以下函數 (每個語言對其的命名可能與標準有點不同):

cache_get(key[,cache])
cache_set(key,value[,expires,cache])
cache_update(key,value[,expires,cache])
cache_exists(key[,cache])
cache_del(key[,cache])
cache_clear([cache])
如果調用該緩存API的語言/平臺區分了字符串和字節 (例如Python 3和Java),那麼你必須假設鍵是字符串,而值是字節 (或者在java之下,是字節數組)。否則,鍵和值都是無特定編碼的字符串,因爲在內部,緩存值和緩存鍵都是簡單的二進制blob。

expires 參數 (默認爲0,表示禁用) 是對象失效的秒數 (並當未設置 purge_lru 的時候,由緩存清道夫移除,見下)

cache 參數是所謂的“魔法標識符”,它的語法是

好了,到這裏這個問題到此解決了一半. 爲什麼說一半呢,因爲這些配置都是普通配置並不是類似mysql,redis的配置信息,這些配置不會再修改配置後重新生成實例,也就沒法使用最新的mysql或redis配置,那麼怎麼辦呢? 下面說說重載服務.

重載服務

如何優化的重啓服務?

命令重啓uwsgi服務

再守護線程的監聽函數最後建加上回調,回調命令函數的實現如下,pid_path是uwsgi啓動後生成的pid文件地址.簡單粗暴但有效.

# 重載uwsgi
def relaod_uwsgi(pid_path):
    """選用方案1"""
    print("------------relaod_uwsgi---------------")
    val = os.system('uwsgi --reload {}'.format(pid_path))
    print(val)
    if val:
        print("重啓可能遇到了問題...")

另闢蹊徑

py-auto-reload
argument: 必需參數

parser: uwsgi_opt_set_int

flags: UWSGI_OPT_THREADS|UWSGI_OPT_MASTER

help: 監控python模塊mtime來觸發重載 (只在開發時使用)

py-autoreload
argument: 必需參數

parser: uwsgi_opt_set_int

flags: UWSGI_OPT_THREADS|UWSGI_OPT_MASTER

help: 監控python模塊mtime來觸發重載 (只在開發時使用)

python-auto-reload
argument: 必需參數

parser: uwsgi_opt_set_int

flags: UWSGI_OPT_THREADS|UWSGI_OPT_MASTER

help: 監控python模塊mtime來觸發重載 (只在開發時使用)

python-autoreload
argument: 必需參數

parser: uwsgi_opt_set_int

flags: UWSGI_OPT_THREADS|UWSGI_OPT_MASTER

help: 監控python模塊mtime來觸發重載 (只在開發時使用)

py-auto-reload-ignore
argument: 必需參數

parser: uwsgi_opt_add_string_list

flags: UWSGI_OPT_THREADS|UWSGI_OPT_MASTER

help: 自動重載掃描期間,忽略指定的模塊 (可以多次指定)

這些配置是監控特定文件來重載uwsgi服務的,那麼我們只要改下localfile的名字爲py結尾,那差不多也是沒問題的.

留下點東西

最後想說點私貨,人類不可能想象出超越意識範圍內的東西,比如做夢,夢中的東西肯定都是平時生活中雞零狗碎的拼湊和僞裝,代碼也是.創新也是.

這裏整理了一個採坑後貢獻出來的python client demo,主要代碼是apollo-client-python中的,我在改了裏面的http請求使用requests,然後做了點淺淺的封裝.歡迎大家star!
這篇隨記也歸檔到了這裏python-mini,也歡迎歡迎大家star!

最後:不要盲目地複製粘貼!

請用腦子想想,試着將顯示的配置調整以適應你的需求,或者創建新的配置。

每個應用和系統都是彼此之間不同的。

作出選擇之前請進行實驗。

上面那句不是我說的,是uwsgi文檔說的.

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章