# python youtube 下載器筆記 ## 安裝套件 ``` pip install pytube pip install beautifulsoup4 pip install requests pip install lxml (執行時期才會報錯) ``` 成果 ![](https://i.imgur.com/HC0GhdB.png) ## 改造開始 改造方向 1. 盡可能移除 lock 2. 用不同的線程模型 ## 移除 lock 移除 lock 最直觀的方法就是 -> 減少 race condition 觀察一下 code 裡面的 race condition 有哪些 ```python #---------------↓ 鎖定區域 A ↓---------------# lock.acquire() # 進行鎖定 no = listbox.size() # 以目前列表框筆數為下載編號 listbox.insert(tk.END, f'{no:02d}:{name}.....下載中') print('插入:', no, name) lock.release() # 釋放鎖定 #---------------↑ 鎖定區域 A ↑---------------# yt.streams.first().download() # 開始下載影片 (不可鎖定) #---------------↓ 鎖定區域 B ↓---------------# lock.acquire() # 進行鎖定 print('更新:', no, name) listbox.delete(no) listbox.insert(no, f'{no:02d}:●{name}.....下載完成') lock.release() # 釋放鎖定 #---------------↑ 鎖定區域 B ↑---------------# ``` 鎖定區域內其實只有編號那邊是會有 race 的... 那為什麼不在外面傳 index 進來就好? 稍微改了一下程式發現其實有 race condition 的是 listbox,雖然我可以用 pass by value 或是 atomic 解決 no 的問題,但輸出的排序會亂.... ```python #--------↓ 下載清單中所有影片 ↓---------# print('開始下載清單') for index, u in enumerate(urls): # 建立與啟動執行緒 threading.Thread(target = m.start_dload, args=(u, listbox, index)).start() #--------↓ 下載單一影片 ↓---------# #---------------↓ 鎖定區域 A ↓---------------# #lock.acquire() # 進行鎖定 no = index # 以目前列表框筆數為下載編號 listbox.insert(tk.END, f'{no:02d}:{name}.....下載中') print('插入:', no, name) #lock.release() # 釋放鎖定 #---------------↑ 鎖定區域 A ↑---------------# yt.streams.first().download() # 開始下載影片 (不可鎖定) #---------------↓ 鎖定區域 B ↓---------------# #lock.acquire() # 進行鎖定 print('更新:', no, name) listbox.delete(no) listbox.insert(no, f'{no:02d}:●{name}.....下載完成') #lock.release() # 釋放鎖定 #---------------↑ 鎖定區域 B ↑---------------# ``` ![](https://i.imgur.com/PWtezg6.png) 還有這樣做其實會有一個很大的問題就是 ![](https://i.imgur.com/2IRV4J4.png) 我感覺他每個下載開一個 thread,效能其實很爛.... 原因參考[context switch](https://zh.wikipedia.org/wiki/%E4%B8%8A%E4%B8%8B%E6%96%87%E4%BA%A4%E6%8F%9B) ## 線程模型改進 最簡單基礎的方法引入 thread pool ,通常語言都會內建不用自己刻 ``` pip install threadpool ``` 用法 ```python pool = ThreadPool(poolsize) # 產生 pool ,指定 pool 內有多少 thread requests = makeRequests(some_callable, list_of_args, callback) # 設定每個 thread 的執行任務,最後一個是選填。 [pool.putRequest(req) for req in requests] # 把所有任務丟進 pool pool.wait() # block 直到所有 thread 返回 ``` 上面是已經棄用的庫,難怪我覺得有夠難用 下面是內建的庫 ```python import concurrent.futures executor = concurrent.futures.ThreadPoolExecutor(max_workers=8) print('開始下載清單') for index, url in enumerate(urls): executor.submit(m.start_dload, url, listbox, index) ``` pool size 通常設為 cpu 上邏輯處理器的數量,在我電腦上是 8。 實際效果 ![](https://i.imgur.com/VueyiKi.png) 去掉網路影響,我們試試看單純計算數字的話效能差距多大 ```python #yt.streams.first().download() # 開始下載影片 (不可鎖定) i = 0 for num in range(1,10000000): i = i + num ``` 8 thread 1563350881.6716301 1563350912.9156864 32 thread 1563350384.8802974 1563350412.9731643 稍微差惹一點科科,參考一下別人的發現 python 居然有 GLI 這種鬼東西 https://zh.wikipedia.org/wiki/%E5%85%A8%E5%B1%80%E8%A7%A3%E9%87%8A%E5%99%A8%E9%94%81 幹其實第七章有講,對不起我低能QQ 真要多執行緒的話請用 `ProcessPoolExecutor` 就可以繞過 GLI 了。 ### 使用 message queue 統一管理對 UI 的操作 今天如果多個函式會在多線程的情況下操作 listbox ,最直覺的方式就是瘋狂上鎖,但這樣寫到後面很容易一個鎖沒上好就大家一起爆炸。 我們有個更簡單更高效率的解決方案 Message queue multiprocessing.Queue 是 python 內建函式庫內少數保證 thread-safe 的物件。 ```python # at main q = Queue() threading.Thread(target = m.read, args=(q, listbox)).start() # at download executor = concurrent.futures.ThreadPoolExecutor(max_workers=16) print('開始下載清單') ticks = time.time() print ("執行前:", ticks) {executor.submit(m.start_dload, url, listbox, q): url for url in urls} ``` 在下載時,將對 listbox 的操作訊息丟入 MessageQueue 內,統一由我們剛剛起的專用 thread 處理,由於只有一個 thread 會處理 listbox ,故也不用上鎖 ```python q.put((name, False)) #lock.release() # 釋放鎖定 #---------------↑ 鎖定區域 A ↑---------------# yt.streams.first().download() # 開始下載影片 (不可鎖定) #---------------↓ 鎖定區域 B ↓---------------# #lock.acquire() # 進行鎖定 #listbox.delete(no) #listbox.insert(no, f'{no:02d}:●{name}.....下載完成') q.put((name, True)) ``` 專用 thread 讀取 MessageQueue 內的訊息並修改 listbox ```python def read(q, listbox): nameMap = {} while True : name, complete = q.get(True) if complete == False : no = listbox.size() listbox.insert(tk.END, f'{no:02d}:{name}.....下載中') nameMap[name] = no else : no = nameMap[name] listbox.delete(no) listbox.insert(no, f'{no:02d}:●{name}.....下載完成') ``` 成果圖 ![](https://i.imgur.com/B2vSOEL.png) ### 火力加強版 thread 控制優化 自己寫個無限迴圈來等待 thread 跑完實在很蠢,來應用 thread pool 解決這個需求 改造前 ```python def multi_dload(urls, listbox): max_thread = threading.activeCount() + 20 #←計算開啟執行緒的數量上 urls.sort(key = lambda s: int(re.search('index=\d+', s).group()[6:])) #←將清單排序 (詳情後述) for url in urls: # 建立與啟動執行緒 while threading.activeCount() >= max_thread: pass threading.Thread(target = m.start_dload, args=(url, listbox)).start() ``` 改造後 ```python def multi_dload(urls, listbox): urls.sort(key = lambda s: int(re.search('index=\d+', s).group()[6:])) #←將清單排序 (詳情後述) executor = concurrent.futures.ThreadPoolExecutor(max_workers=20) print('開始下載清單') for url in urls: executor.submit(m.start_dload, url, listbox) ``` ## title 無效 取 youtube 物件的 title 時會炸掉 ![](https://i.imgur.com/2usxbK0.png) 前天更新才出現的問題,上 github 找一下有沒有人討論 ![](https://i.imgur.com/uiOjX03.png) 第一個就是 ![](https://i.imgur.com/sK8U4Ot.png) 看起來這個 commit 可以修好 ![](https://i.imgur.com/4Pi8G7p.png) 把對應的 code 改一下就正常惹 ## 加入 SQLlite ### 建立資料庫 先建立對應 db,使用工具(DB Browser for SQLite) ![](https://i.imgur.com/nFKdsps.png) 記得根據後續會搜尋的條件設定 Index,這樣搜尋的時間就會從 O(N) 降為 O(logN) 寫好對 db 操作的指令,參考 http://www.runoob.com/sqlite/sqlite-python.html ```python def queryVideo(name, url, db): c = db.cursor() sql = str(f"SELECT F_ID FROM T_VIDEO \ WHERE F_Name == '{name}' AND F_URL == '{url}'") cursor = c.execute(sql) for row in cursor: print ("ID = ", row[0], "\n") return True return False def addVideo(name, url, db): c = db.cursor() c.execute(f"INSERT INTO T_VIDEO (F_Name, F_URL) \ VALUES ('{name}', '{url}')") db.commit() ``` 開跑,然後炸掉 ![](https://i.imgur.com/7kMHx8N.png) 很好看來我每次用都得開個 connection,應該是 SQLite 非 thread-safe 才會這樣限制 修改成每個 thread 各自連線 DB ```python #------------↓ SQLite ↓------------# db = sqlite3.connect('hdb.db') yt = YouTube(url) name = yt.title if queryVideo(name, url, db) == True : print(f'Video {name} at {url} already being download') db.close() return #.... print ("執行後:", ticks) addVideo(name, url, db) ``` 第一次下載 ![](https://i.imgur.com/0PVZca1.png) 第二次下載 ![](https://i.imgur.com/yLxnmRW.png) 可以看出不會重複下載了 ![](https://i.imgur.com/i18Mlxh.png) DB打開來就可以看到資料更新了 ![](https://i.imgur.com/vJYm8Jf.png) ## 總結 GLI IO/CPU python thread process thread pool message queue SQLite