中文字幕在线观看,亚洲а∨天堂久久精品9966,亚洲成a人片在线观看你懂的,亚洲av成人片无码网站,亚洲国产精品无码久久久五月天

Python 中的多進程與線程 每個數(shù)據(jù)科學(xué)家都需要知道

2020-01-08    來源:raincent

容器云強勢上線!快速搭建集群,上萬Linux鏡像隨意使用

本文最初發(fā)布于 FLOYDHUB 博客,原作者:Sumit Ghosh

導(dǎo)讀:線程和進程都是現(xiàn)在計算機領(lǐng)域比較時髦的用語。進程 (Process) 是計算機中已運行程序的實體。進程本身不會運行,是線程的容器。程序本身只是指令的集合,進程才是程序(那些指令) 的真正運行。若干進程有可能與同一個程序相關(guān)系,且每個進程皆可以同步(循序) 或不同步(平行) 的方式獨立運行。進程為現(xiàn)今分時系統(tǒng)的基本運作單位。線程(thread),操作系統(tǒng)技術(shù)中的術(shù)語,是操作系統(tǒng)能夠進行運算調(diào)度的最小單位。它被包含在進程之中,一條線程指的是進程中一個單一順序的控制流,一個進程中可以并發(fā)多個線程,每條線程并行執(zhí)行不同的任務(wù)。今天,我們翻譯并分享 Sumit Ghosh 撰寫的關(guān)于 Python 中的多進程與線程的方方面面,這些內(nèi)容對于每個有志于成為數(shù)據(jù)科學(xué)家的從業(yè)者都是應(yīng)知必會的內(nèi)容。

 

 

每個數(shù)據(jù)科學(xué)項目遲早都會面臨一個不可避免的挑戰(zhàn):速度。使用更大的數(shù)據(jù)集會導(dǎo)致處理速度變慢,因此,最終不得不考慮優(yōu)化算法的運行時間。正如大多數(shù)人所知道的,并行化就是這種優(yōu)化的必要步驟。Python 為并行化提供了兩個內(nèi)置庫:multiprocessing(多進程)和 threading(線程)。在本文中,我們將探討數(shù)據(jù)科學(xué)家如何在這兩者之間進行選擇,以及在選擇時應(yīng)記住哪些因素。

并行計算與數(shù)據(jù)科學(xué)

眾所周知,數(shù)據(jù)科學(xué)是一門處理大量數(shù)據(jù),并從中提取有用見解的科學(xué)。通常情況下,我們在數(shù)據(jù)上執(zhí)行的操作很容易實現(xiàn)并行化,這意味著不同的處理可以在數(shù)據(jù)上一次運行一個操作,然后在最后將結(jié)果組合起來以得到完整的結(jié)果。

為了更好地理解并行性,讓我們考慮一個真實世界的類比。假設(shè)你需要打掃家里的三個房間,你可以一個人包攬所有的事情:一個接一個地打掃房間,或者你也可以叫來兩個人幫助你,你們每個人只打掃一個房間。在后一種方法中,每個人都并行地處理整個任務(wù)的一部分,從而減少完成了任務(wù)所需的總時間,這就是并行性。

在 Python 中,可以通過兩種不同的方式實現(xiàn)并行處理:多進程和線程。

多進程和線程:理論

從根本上來說,多進程和線程是實現(xiàn)并行計算的兩種方法,分別使用進程和線程作為處理代理。要了解這些方法的工作原理,我們就必須弄清楚什么是進程,什么是線程。

 

 

進程

進程是正在執(zhí)行的計算機程序的實例。每個進程都有自己的內(nèi)存空間,用于存儲正在運行的指令,以及需要存儲和訪問用來執(zhí)行的任何數(shù)據(jù)。

線程

線程是進程的組件,可以并行運行。一個進程可以有多個線程,它們共享相同的內(nèi)存空間,即父進程的內(nèi)存空間。這意味著要執(zhí)行的代碼以及程序中聲明的所有變量,將由所有線程共享。

 

 

進程和線程(圖:筆者與 Cburnett)

例如,讓我們考慮一下你的計算機上正在運行的程序。你可能正在瀏覽器中閱讀本文,瀏覽器可能打開了多個標(biāo)簽頁。你還可能同時通過 Spotify 桌面應(yīng)用收聽音樂。瀏覽器和 Spotify 應(yīng)用程序是不同的進程,它們中的每一個都可以使用多個進程或線程來實現(xiàn)并行性。瀏覽器中的不同標(biāo)簽頁可能在不同的線程中運行。Spotify 可以在一個線程中播放音樂,在另一個線程中從互聯(lián)網(wǎng)下載音樂,并使用第三個線程來顯示 GUI。這就叫做多線程(multithreading)。多進程(多即個進程)也可以做到這一點。事實上,大多數(shù)像 Chrome 和 Firefox 這樣的現(xiàn)代瀏覽器使用的是多進程而不是多線程來處理多個標(biāo)簽。

技術(shù)細節(jié)

一個進程的所有線程都位于同一個內(nèi)存空間中,而進程有各自獨立的內(nèi)存空間。

與進程相比,線程更輕量級,并且開銷更低。生成進程比生成線程要慢一些。

在線程之間共享對象更容易,因為它們共享的是相同的內(nèi)存空間。為了在進程之間實現(xiàn)同樣的效果,我們必須使用一些類似 IPC(inter-process communication,進程間通信)模型,通常是由操作系統(tǒng)提供的。

并行計算的陷阱

在程序中引入并行性并不總是一個正和博弈;有一些需要注意的陷阱。最重要的陷阱如下:

競態(tài)條件: 正如我們已經(jīng)討論過的,線程具有共享的內(nèi)存空間,因此它們可以訪問共享變量。當(dāng)多個線程試圖通過同時更改同一個變量時,就會出現(xiàn)競態(tài)條件。線程調(diào)度程序可以在線程之間任意切換,因此,我們無法知曉線程將試圖更改數(shù)據(jù)的順序。這可能導(dǎo)致兩個線程中的任何一個出現(xiàn)不正確的行為,特別是如果線程決定基于變量的值執(zhí)行某些操作時。為了防止這種情況的發(fā)生,可以在修改變量的代碼段周圍放置互斥鎖,這樣,一次只能有一個線程可以寫入變量。

饑餓: 當(dāng)線程在更長的時間內(nèi)被拒絕訪問特定資源時,就會發(fā)生饑餓,因此,整個程序速度就會變慢。這可能是涉及不良的線程調(diào)度算法的意外副作用。

死鎖: 過度使用互斥鎖也有一個缺點,它可能會在程序中引入死鎖。死鎖是一個線程等待另一個線程將鎖釋放,但另一個線程需要一個資源來完成第一個線程所持有的鎖。這樣,兩個線程都會停止,程序也隨之停止。死鎖可以被看作是饑餓的一種極端情況。要避免這種情況,我們必須小心不要引入太多相互依賴的鎖。

活鎖: 活鎖是指線程在循環(huán)中繼續(xù)運行,但沒有任何進展。這也是由于涉及不良和互斥鎖的使用不當(dāng)造成的。

Python 中的多進程和線程

全局解釋鎖

當(dāng)涉及到 Python 時,有一些奇怪的地方需要記住。我們知道,線程共享相同的內(nèi)存空間,因此必須采取特殊的預(yù)防措施,以便兩個線程不會寫入相同的內(nèi)存位置。CPython 解釋器使用一種名為 GIL 的機制或全局解釋鎖來處理這個問題。

摘自 Python 的官方 wiki :

在 CPython 中,全局解釋鎖(Global Interpreter Lock,GIL)是一個互斥鎖,用來保護對 Python 對象的訪問,防止多個線程同時執(zhí)行 Python 字節(jié)碼。這種鎖是必要的,主要是因為 CPython 的內(nèi)存管理不是線程安全的。

請查看這個幻燈片來了解 Python GIL 的詳細信息: Understanding the Python GIL

GIL 完成了它的工作,但是也付出了代價。它有效地序列化了解釋器級別的指令。它的工作原理如下:任何線程要執(zhí)行任何函數(shù),都必須獲取全局鎖。一次只能有一個線程可以獲得全局鎖,這意味著解釋器最終會串行地運行指令。這種設(shè)計使內(nèi)存管理做到線程安全,但結(jié)果是,它根本不能利用多個 CPU 內(nèi)核。在單核 CPU 中(這正是設(shè)計師在開發(fā) CPython 時所考慮的),這并不是什么大問題。但是,如果使用多核 CPU 的話,那么這個全局鎖最終將會成為一個瓶頸了。

如果你的程序在其他地方存在更嚴重的瓶頸,例如在網(wǎng)絡(luò)、IO、或者用戶交互方面,那么全局鎖這個瓶頸就變得無關(guān)緊要了。在這些情況下,線程化是一種完全有效的并行化方法。但對于計算密集型(CPU bound)的程序,線程化最終會使程序變慢。讓我們通過一些用例來探討這個問題。

線程用例

GUI 程序始終使用線程來使應(yīng)用程序作出響應(yīng)。例如,在文本編輯程序中,一個線程負責(zé)記錄用戶輸入,另一個線程負責(zé)顯示文本,第三個線程負責(zé)拼寫檢查,等等。在這里,程序必須等待用戶交互,這是最大的瓶頸。使用多進程并不會使程序變得更快。

線程處理的另一個用例是 IO 密集型(IO bound)或網(wǎng)絡(luò)密集型的程序,比如 Web Scraper 。在這種情況下,多個線程可以負責(zé)并行抓取多個 Web 網(wǎng)頁。線程必須從互聯(lián)網(wǎng)上下載網(wǎng)頁,這將是最大的瓶頸,因此線程對于這種情況來說是一個完美的解決方案。網(wǎng)絡(luò)密集型的 Web 服務(wù)器的工作方式類似:對于它們這種情況,多進程并不比線程有任何優(yōu)勢。另一個相關(guān)的例子是 TensorFlow ,它使用線程池(thread pool)來并行地轉(zhuǎn)換數(shù)據(jù)。

多進程的用例

在程序是計算密集型的,且不需要進行任何 IO 或用戶交互的情況下,那么多進程就比線程處理更為出色。例如,任何只處理數(shù)字的程序都將從多進程中獲得巨大的加速;事實上,線程化處理可能會降低它的運行速度。一個有趣的實際例子是 Pytorch Dataloader ,它使用多個子進程將數(shù)據(jù)加載到 GPU 中。

在 Python 中的并行化

Python 為并行化方法提供了兩個同名的庫: multiprocessing 和 threading。盡管它們之間存在根本的不同,但這兩個庫提供了非常相似的 API(從 Python 3.7 開始)。讓我們看看它們的實際應(yīng)用。

import threading
import random
from functools import reduce

def func(number):
random_list = random.sample(range(1000000), number)
return reduce(lambda x, y: x*y, random_list)

number = 50000
thread1 = threading.Thread(target=func, args=(number,))
thread2 = threading.Thread(target=func, args=(number,))

thread1.start()
thread2.start()

thread1.join()
thread2.join()

你可以看到,我創(chuàng)建一個函數(shù) func,它創(chuàng)建了一個隨機數(shù)列表,然后按順序?qū)⑵渲械乃性叵喑。如果項目?shù)量足夠大,比如 5 萬或 10 萬,這可能是一個相當(dāng)繁重的過程。

然后,我創(chuàng)建了兩個線程,它們將執(zhí)行相同的函數(shù)。線程對象有一個異步啟動線程的 start 方法。如果我們想等待它們終止并返回,就必須調(diào)用 join 方法,這就是我們這段代碼所做的事情。

正如你所見,在后臺將一個新線程轉(zhuǎn)化為一個任務(wù)的 API 非常簡單。很棒的是,用于多進程的 API 也幾乎完全相同;讓我們來看一下。

import multiprocessing
import random
from functools import reduce

def func(number):
random_list = random.sample(range(1000000), number)
return reduce(lambda x, y: x*y, random_list)

number = 50000
process1 = multiprocessing.Process(target=func, args=(number,))
process2 = multiprocessing.Process(target=func, args=(number,))

process1.start()
process2.start()

process1.join()
process2.join()

代碼就是這樣的,只需將 multiprocessing.Process 與 threading.Thread 進行交換。 你使用多進程實現(xiàn)了完全相同的程序。

很顯然,你可以用它做更多的事情,但這已不在本文的范疇之內(nèi),因此我們將不再贅述。如果有興趣了解更多相關(guān)信息,請查看 threading — Thread-based parallelism。

基準

現(xiàn)在我們已經(jīng)了解了實現(xiàn)并行化的代碼是什么樣子的,讓我們回到性能問題上來。正如我們之前所指出的,線程處理不適合計算密集型任務(wù)。在這種情況下,它最終會成為瓶頸。我們可以使用一些簡單的基準來驗證這一點。

首先,讓我們看看上面所展示的代碼示例中線程和多進程的比較。請記住,此任務(wù)不涉及任何類型的 IO,因此它是純計算密集型的任務(wù)。

 

 

讓我們來看看 IO 密集型的任務(wù)的類似基準測試。例如,下面的函數(shù):

import requests

def func(number):
url = 'http://example.com/'
for i in range(number):
response = requests.get(url)
with open('example.com.txt', 'w') as output:
output.write(response.text)

 

這個函數(shù)的作用就是只獲取一個網(wǎng)頁,并將其保存到本地文件中,如此循環(huán)多次。雖然沒有什么用,但是很直接,因此非常適合用來演示。讓我們看一下基準測試。

 

 

從這兩張圖表中可以注意到以下幾點:

在這兩種情況下,單個進程比單個線程花費更多的執(zhí)行時間。顯然,進程的開銷比線程更大。

對于計算密集型的任務(wù),多個進程的性能要比多個線程的性能要好得多。然而,當(dāng)我們使用 8x 并行化時,這種差異就變得不那么明顯了。由于我的筆記本的 CPU 是四核的,因此最多可以有四個進程有效地使用多個內(nèi)核。因此,當(dāng)我使用更多進程時,它就不能很好地進行擴展。但是,它的性能仍然比線程要好很多,因為線程根本就不能利用多核。

對于 IO 密集型的任務(wù),那么 CPU 就不是瓶頸了。因此,GIL 的常見限制在這里并不適用,而多進程也沒有什么優(yōu)勢。不僅如此,線程的輕量級開銷實際上使它們比多進程更快,而且線程的性能始終優(yōu)于多進程。

區(qū)別、優(yōu)點和缺點

線程在同一個內(nèi)存空間中運行;進程有獨立的內(nèi)存。

從之前的一點開始:線程之間共享對象更容易,但問題的另一面是,你必須采取額外的措施來進行對象同步,確保兩個線程不會同時寫入同一個對象,不會發(fā)生競態(tài)條件。

由于對象同步增加了編程開銷,多線程編程更容易出錯。而另一方面,多進程編程則很容易實現(xiàn)。

與進程相比,線程的開銷更低;生成進程比線程花費更多的時間。

由于 Python 中 GIL 的局限性,線程無法利用多個 CPU 內(nèi)核實現(xiàn)真正的并行化。而多進程則沒有任何這樣的限制。

進程調(diào)度由 OS 處理,而線程調(diào)度由 Python 解釋器來完成。

子進程是可中斷、可終止的,而子線程則不是。你必須等待線程終止或 join。

從所有這些討論中,我們可以得出以下結(jié)論:

線程應(yīng)該用于涉及 IO 或用戶交互的程序。

多進程應(yīng)該用于計算密集型程序。

站在數(shù)據(jù)科學(xué)家的角度來看

典型的數(shù)據(jù)處理管道可以分為以下幾個步驟:

讀取?數(shù)據(jù)并存儲到主存儲器或 GPU 中。

使用 CPU 或 GPU 進行計算。

將挖掘出的信息存儲在數(shù)據(jù)庫或磁盤中。

讓我們探索一下如何在這些任務(wù)中引入并行性,以便加快它們的運行速度。

步驟 1 涉及從磁盤讀取數(shù)據(jù),因此顯然磁盤 IO 將成為這一步驟的瓶頸。正如我們已經(jīng)討論過的,線程是并行化這種操作的最佳選擇。類似地,步驟 3 也是引入線程的理想候選步驟。

但是,步驟 2 包括了涉及 CPU 或 GPU 的計算。如果它是一個基于 CPU 的任務(wù),那么使用線程就沒有用;相反,我們必須進行多進程。只有這樣,我們才能充分利用 CPU 的多核并實現(xiàn)并行性。如果它是基于 GPU 的任務(wù),由于 GPU 已經(jīng)在硬件級別上實現(xiàn)了大規(guī)模并行化架構(gòu),使用正確的接口(庫和驅(qū)動程序)與 GPU 交互應(yīng)該會解決其余的問題。

 

 

現(xiàn)在你可能會想,“恐怕我的數(shù)據(jù)管道看起來有點不同啊;我有一些任務(wù)并不完全適合這個通用框架啊。”不過,你應(yīng)該能夠觀察到此處用來決定線程和多進程之間的關(guān)系。你應(yīng)該考慮的因素包括:

你的任務(wù)是否具有任何形式的 IO?

IO 是否為程序的瓶頸?

你的任務(wù)是否依賴于 CPU 的大量計算?

考慮到這些因素,再加上上面提到的要點,你應(yīng)該能夠作出自己的決定了。另外,請記住,你不必在整個程序中,使用單一形式的并行化。你應(yīng)該為程序的不同部分使用其中一種或另一種形式的并行化,以適合該特定部分的為準。

現(xiàn)在,我們來看一下數(shù)據(jù)科學(xué)家可能面臨的兩個示例場景,以及如何使用并行計算來加速它們。

場景:下載電子郵件

假設(shè)你想分析你自己的創(chuàng)業(yè)公司收件箱里的所有電子郵件,并了解趨勢:誰是發(fā)送頻率最高的發(fā)件人,在電子郵件中出現(xiàn)的最常見的關(guān)鍵詞是什么,一周中的哪一天或者一天中的哪個時段收到的電子郵件最多,等等。當(dāng)然,這個項目的第一步是將電子郵件下載到你的電腦上。

首先,讓我們按順序執(zhí)行,不使用任何并行化。下面是要使用的代碼,它應(yīng)該很容易理解。有一個函數(shù) download_emails,它將電子郵件的 ID 列表作為輸入,并按順序下載它們。這會將此函數(shù)與一次 100 封電子郵件列表的 ID 一起調(diào)用。

import imaplib
import time

IMAP_SERVER = 'imap.gmail.com'
USERNAME = 'username@gmail.com'
PASSWORD = 'password'

def download_emails(ids):
client = imaplib.IMAP4_SSL(IMAP_SERVER)
client.login(USERNAME, PASSWORD)
client.select()
for i in ids:
print(f'Downloading mail id: {i.decode()}')
_, data = client.fetch(i, '(RFC822)')
with open(f'emails/{i.decode()}.eml', 'wb') as f:
f.write(data 0 )
client.close()
print(f'Downloaded {len(ids)} mails!')

start = time.time()

client = imaplib.IMAP4_SSL(IMAP_SERVER)
client.login(USERNAME, PASSWORD)
client.select()
_, ids = client.search(None, 'ALL')
ids = ids[0].split()
ids = ids[:100]
client.close()

download_emails(ids)
print('Time:', time.time() - start)

Time taken :: 35.65300488471985 seconds.

現(xiàn)在,讓我們在這個任務(wù)中引入一些并行化能力,以加快速度。在開始編寫代碼之前,我們必須在線程和多進程之間作出決定。正如你到目前為止所了解的那樣,當(dāng)涉及到 IO 為瓶頸的任務(wù)時,線程就是最佳選擇。手邊的任務(wù)顯然屬于這一類,因為它是通過互聯(lián)網(wǎng)訪問 IMAP 服務(wù)器。因此我們將使用 threading(線程)。

我們將要使用的大部分代碼與我們在順序情況下使用的代碼是相同的。唯一的區(qū)別是,我們將 100 封電子郵件的 ID 列表拆分為 10 個較小的塊,每一塊包含 10 個 ID,然后創(chuàng)建 10 個線程并使用不同的塊調(diào)用函數(shù) download_emails。我使用 Python 標(biāo)準庫中的 concurrent.futures.ThreadPoolExecutor 類進行線程化處理。

import imaplib
import time
from concurrent.futures import ThreadPoolExecutor

IMAP_SERVER = 'imap.gmail.com'
USERNAME = 'username@gmail.com'
PASSWORD = 'password'

def download_emails(ids):
client = imaplib.IMAP4_SSL(IMAP_SERVER)
client.login(USERNAME, PASSWORD)
client.select()
for i in ids:
print(f'Downloading mail id: {i.decode()}')
_, data = client.fetch(i, '(RFC822)')
with open(f'emails/{i.decode()}.eml', 'wb') as f:
f.write(data 0 )
client.close()
print(f'Downloaded {len(ids)} mails!')

start = time.time()

client = imaplib.IMAP4_SSL(IMAP_SERVER)
client.login(USERNAME, PASSWORD)
client.select()
_, ids = client.search(None, 'ALL')
ids = ids[0].split()
ids = ids[:100]
client.close()

number_of_chunks = 10
chunk_size = 10
executor = ThreadPoolExecutor(max_workers=number_of_chunks)
futures = []
for i in range(number_of_chunks):
chunk = ids[i*chunk_size:(i+1)*chunk_size]
futures.append(executor.submit(download_emails, chunk))

for future in concurrent.futures.as_completed(futures):
pass
print('Time:', time.time() - start)

Time taken :: 9.841094255447388 seconds.

正如你所看到的,線程化大大加快了執(zhí)行速度。

場景:使用 Scikit-Learn 進行分類

假設(shè)你有一個分類問題,想為此使用隨機森林(random forest)分類器。因為它是一種標(biāo)準的、眾所周知的機器學(xué)習(xí)方法,所以我們不打算“重新發(fā)明輪子 ”,只使用 sklearn.ensemble.RandomForestClassifier。

下面的代碼段用于演示目的。我使用輔助函數(shù) sklearn.datasets.make_classification 創(chuàng)建了一個分類數(shù)據(jù)集,然后在此基礎(chǔ)上訓(xùn)練了一個 RandomForestClassifier 。此外,我正在對代碼中做核心工作的部分進行計時,以對模型進行擬合。

from sklearn.ensemble import RandomForestClassifier
from sklearn import datasets
import time

X, y = datasets.make_classification(n_samples=10000, n_features=50, n_informative=20, n_classes=10)

start = time.time()
model = RandomForestClassifier(n_estimators=500)
model.fit(X, y)
print('Time:', time.time()-start)

Time taken :: 34.17733192443848 seconds.

現(xiàn)在,我們來看看如何減少這個算法的運行時間。我們知道這個算法在一定程度上實行并行化,但什么樣的并行化才是合適的呢?它沒有任何 IO 瓶頸;相反,這是一項非常耗費 CPU 的任務(wù)。因此,多進程將是合理的選擇。

幸運的是, sklearn 已經(jīng)在這個算法中實現(xiàn)了多進程,我們不必從頭開始編寫。正如你在下面的代碼中所看到的那樣,我們只需提供一個參數(shù) n_jobs ,即它應(yīng)該使用的進程數(shù)量,來啟用多進程。

from sklearn.ensemble import RandomForestClassifier
from sklearn import datasets
import time

X, y = datasets.make_classification(n_samples=10000, n_features=50, n_informative=20, n_classes=10)

start = time.time()
model = RandomForestClassifier(n_estimators=500, n_jobs=4)
model.fit(X, y)
print('Time:', time.time()-start)

Time taken :: 14.576200723648071 seconds.

正如預(yù)期的那樣,多進程使其運行速度提高了很多。

結(jié)論

大多數(shù)(如果不是所有的話)數(shù)據(jù)科學(xué)項目將會看到并行計算速度大幅提高。事實上,許多流行的數(shù)據(jù)科學(xué)庫已經(jīng)內(nèi)置了并行性,你只需啟用它即可。因此,在嘗試自己實現(xiàn)它之前,請先查看正在使用的庫的文檔,并檢查它是否支持并行性(順便說一句,我強烈建議你查看 dask )。如果沒有的話,希望本文能夠幫助你自己來實現(xiàn)并行性。

作者介紹:

Sumit 是一名計算機愛好者,很小就開始編程。目前正在德里印度理工學(xué)院(IIT Delhi)攻讀計算機科學(xué)碩士學(xué)位。除了編程之外,他還喜歡哲學(xué)、吉他、攝影和寫作。

原文鏈接:

Multiprocessing vs. Threading in Python: What Every Data Scientist Needs to Know

標(biāo)簽: Python 數(shù)據(jù) 蒲Ъ

版權(quán)申明:本站文章部分自網(wǎng)絡(luò),如有侵權(quán),請聯(lián)系:west999com@outlook.com
特別注意:本站所有轉(zhuǎn)載文章言論不代表本站觀點!
本站所提供的圖片等素材,版權(quán)歸原作者所有,如需使用,請與原作者聯(lián)系。

上一篇:揭秘騰訊TDSQL全時態(tài)數(shù)據(jù)庫系統(tǒng)

下一篇:申請數(shù)據(jù)科學(xué)家職位被拒,我開始研究他們都是些什么人