動手實現簡易端口掃描器——PortScanner

效果展示

實現效果圖
本地服務器狀態圖:
服務器運行情況

前言

系列介紹

重新翻看了下博客,最近的一篇小工具實現類的文章是一年前寫的( 聊天室傳送門)。再次審看的時候,發現了裏面許多描述上的小錯誤,有的是概念上的不準確,有的是理解上的片面。還是希望大家閱讀的時候,能夠有自己的思考,有自己的判斷。有撰寫錯誤的地方,也請評論指出。

早些時候,我會將大家建設性的評論貼近文章裏,但是在博客網站更新過幾次後,每次修改都會進入審覈隊列,效率太低了。所以我初步打算將特殊的評論置頂(雖然我並不清楚能不能做到),如果大家閱讀過程中提出了疑問,可以優先查看置頂的幾條評論,也許裏面就有你想要提出的問題。

在整篇文章開始前啊,我還要囉嗦很長一段時間,時間緊的小夥伴可以通過目錄跳轉哈(我相信,時間緊的小夥伴根本不會看我寫的東西,因爲都是垃圾…)。

閱讀完我自己寫的文章後,我發現我會經常性的使用兩種修辭手法——舉例子和打比方(語文小課堂開啓)。這兩中手法貌似沒什麼區別,但是我認爲,這是我能夠啓發大家想問題的最主要方式。

舉例子&打比方

當我們嘗試描述一件事物或是一個定理時,會試圖尋找一個身邊的常見情形,而這個情形中就具有待描述的事物,或者情形本身就運用了這個定理,這個時候我們就是舉了個實際運用所描述定理的例子。

舉個例子,“人們喜歡喫甜食,比如蛋糕、巧克力、冰激凌…”,其中的具體食物,就是對“甜食”的舉例。(而我這段話本身,就是對“舉例子的應用”本身的舉例。邏輯上還能理清嗎?手動偷笑)

而打比方,我更願意將它理解爲:用一個已知事物(或定理)據用的特性,片面描述新鮮事物的方法

舉個例子,我想要描述“我喜歡你”這件事情,我會打個比方說“我喜歡你,就像你媽打你,不講道理。”“喜歡你”和“你媽打你”之間有什麼聯繫嗎?其實並沒有,只是你媽媽打你從來不講道理,這個“不講道理”的性質,和“我喜歡你”很相像,所以用它打比方來說明“沒道理”這個特性。而不是“喜歡你”和“媽媽打你”這兩件事情很像。所以,我們在遇到打比方手法的時候,個人只是想用這件事情的某方面特徵來類比正在學習的新事物的某個特徵,加深理解而已。大家不應該糾結在兩個事物的相似度上。以上,語文課結束。

何爲端口

既然要對端口進行掃描,首先來認識下什麼是端口。端口,從大概念上籠統的分,可以分爲兩類——物理端口和虛擬端口。

物理端口

物理端口,又常被稱作“接口”。舉個例子,學生們的個人計算機一般以筆記本爲主,很少有使用座機的。但是筆記本的鍵盤小,而且敲擊快感欠缺,我們都會外接一個鍵盤。筆記本給鍵盤預留的接口,可以籠統的認爲是物理端口。

在用戶敲下鍵盤按鍵時,按鍵信息會被傳送到端口處,但不會直接交給CPU。在端口處,有一小塊的緩存區域,輸入數據先暫存在這塊存儲空間裏,等着CPU來取。如果大家學習彙編預言時學的是8086系列,我們也能想到,CPU從端口讀取數據和向端口傳輸數據的命令分別是in和out,此處就是對這種情況的描述。

我們知道端口的英文單詞是“port”,它有港口的意思。鍵盤將數據傳送到端口,可以類比貨船將貨物卸到港口。

我們經常說CPU,它本質上是個什麼東西呢?其實概念上並沒有什麼複雜的,CPU是一塊小型的芯片,這塊芯片完成着計算機中所有的計算、尋址、取數據等等工作。但是計算機中的芯片,並不只有CPU一種。

舉個例子,另一個我們經常提到的計算機組件——顯卡。

計算機將畫面輸出到顯示器上,可不是一氣呵成的,也不是CPU一個人能夠完成的。顯卡處,也有一小塊內存,我們一般叫做顯存。CPU做的事情呢,也就只是將要顯示的數據放進顯存裏而已,之後其他的事情,它就不管了。那,把數據放到顯存裏,數據就能顯示到顯示器上嗎?當然不能,在顯卡處,也有一個芯片,它的功能沒有CPU那麼豐富,它只做一件事——將顯存裏的數據顯示到頁面上。因爲CPU不做這件事情,所以我們在學習彙編的時候從來不管這一步,只需要知道顯存的內存地址,然後控制CPU將顯示數據放進去即可。

現在知道,爲什麼我們在玩一款畫質非常優質的遊戲時,會選擇換一個好的顯卡,而不是換個CPU了嗎。因爲即使你換了,畫面也不會有大的變化,頂多是不再卡頓了。

無論是鍵盤處的緩存,還是顯卡處的顯存,對於CPU來說都是一塊連續的邏輯內存,爲每個存儲空間編上號碼,能夠訪問即可。這裏就是物理空間上分離的內存,相對CPU而言是邏輯上一大塊連續內存的概念解釋。

綜上,對於物理端口的概念,可以籠統的理解爲由物理接口、緩存、芯片共同組成的能夠具有一定功能的整體。(個人觀點,並不準確)

虛擬端口

虛擬端口,不像物理端口一樣有個具體的接口,它更多的用在計算機內部程序之間的通信,它的出現需要從多任務操作系統引入。

在計算機發展的最開始階段,使用的操作系統爲單任務的。像現在一樣一邊放着音樂,一邊看博客是不太可能的事情。單任務操作系統在同一時間只能做一件事情,很單純,很專一。但是隨着用戶對多任務的需求,單任務OS漸漸不能勝任,於是有了多任務OS。在操作系統之上,同時運行着多個進程,同時進行着多個任務。隨之而來的,還有個問題,進程多了,當我需要用到其中一個進程時,怎麼找到它呢?那就給每個進程分配個號碼吧。

計算機給進程分配了個端口,又叫“協議端口”。當兩臺計算機之間的不同進程需要通信時,就可以根據端口找到通信進程。舉個例子,打開計算機,第一件事“啓動聊天工具”,然後“打開瀏覽器訪問博客網站”。我們知道,聊天軟件是一個進程,瀏覽器是一個進程,當我們訪問博客網站時,遠程服務器會將數據包發送到我們的計算機上,那是怎麼精確地交給了瀏覽器,而不是交給聊天軟件呢?就是依靠端口。

虛擬端口,或者說協議端口,用兩個字節大小保存,也就是2^16=65536個端口號。一般1024之後的端口號纔會分配給普通進程,之前的端口號會約定的分配給某些服務進程。

依舊拿網站服務器舉例,我們經常說服務器服務器的,那什麼是服務器呢?個人認爲,這也是個籠統的概念,拿我們現在的處境來說,博客網站的遠程服務器,就是一臺計算機。只不過它可以對外提供服務而已,我們訪問這臺計算機,它返回給我們的網頁就是它對外提供的一種“服務”,而這個“服務”在這臺計算機上,是一個運行着的進程,這個進程有一個端口號,我們就是通過訪問這臺計算機上的固定端口號上的進程來獲取“服務”的。IP用來確定互聯網世界的某一臺機器,端口用來確定這臺機器上的進程,僅此而已。

而當我們自己在本地搭建服務器時,首先會選擇一個容器,比如Apache,比如Tomcat。然後將邏輯代碼放進容器裏面,並把容器進程運行在一個端口上,這臺計算機才具有了對外提供服務的功能。此時,對於本機而言,運行在本機上的具有一定功能的容器,我們也叫做服務器,但它的概念,與上面那個不同。在我們平常的溝通中,你說你搭過網站,別人問你用的服務器是什麼的時候。你會回答Apache或者Tomcat,此時這個容器本身又被我們叫做了服務器。所以啊,服務器指示什麼,需要我們根據情境而定了。

“協議端口”,說了端口,接下來我們說下“協議”。什麼是“協議”呢?其實我的理解就是一套規則,規則制定好了之後,大家各自去實現,只要大家都按規則辦事,這件事情就能辦成。

舉個例子,HTML語言,就是指定了一套規則,比如a標籤應該被解析成超鏈接,img標籤應該被解析成圖片。規則指定好了,具體怎麼實現是瀏覽器廠家的事情。這也是爲什麼,不同的瀏覽器對同一個頁面解析會有一些差距的原因,但只要不影響我們正常的使用,就可以。

互聯網的世界中,兩臺計算機通信使用的最常用的協議就是HTTP協議,概念上它也沒有多麼神祕。只是兩臺計算機通信時遵守的規則而已,如果我說漢語,你說鳥語,那我們嗓子喊禿嚕皮兒了,也不知道對方說了什麼。這套協議規則,就可以簡單的理解成兩人聊天之前的“語言統一”。

而HTTP協議進程,一般運行在80端口上。我們經常用到這個端口嗎?是的,擡頭看瀏覽器的地址欄裏http://blog.csdn.net,這個網頁的傳輸就是使用的HTTP協議。換而言之,網站的服務器主機在80端口,運行着HTTP服務進程,我們通過訪問這個80端口(在請求頁面時,瀏覽器會自動添加80端口號,無需我們自己操作。而如果服務器容器沒有運行在80端口時,就需要我們手動添加了。例如,本地的Tomcat服務器,訪問地址http://localhost:8080/index.html等等。),獲得了服務器提供給我們的“服務”。類似的,還有一些其他的服務,運行在不同的端口上,而這些重要的服務進程,一般情況下端口是固定的(管理員可以修改)。我們就可以嘗試掃描主機開放的端口,來猜測它提供的服務。例如FTP(文件傳輸)協議,一般運行在21端口上,如果我們檢測目標主機21端口開發,就可以猜測主機提供文件的上傳下載等服務。

而對於提供的不同服務,根據類型不同,又可分爲TCP端口和UDP端口。

TCP&UDP

網絡協議的制定相當複雜,採用了分層的策略,底層協議相對簡單,併爲上層協議提供服務。其形式類似於我們編寫代碼的時候,對一些底層操作的封裝,在邏輯層直接調用方法或對象,而不考慮它的具體實現一樣,如此,即可將精力主要放在邏輯功能的編寫上。

TCP和UDP是傳輸層的兩個重要協議,爲上層的應用層提供服務,HTTP協議就是應用層協議的其中之一。網絡基礎知識我們不再多談,可以參考開頭給的聊天室的傳送門,但請慎重閱讀,裏面有一些描述上的小瑕疵,修改的效率太低,請自行判斷。

對於TCP和UDP兩個協議的區別,主要在於它們提供的服務的特性。TCP協議提供可靠的服務,UDP相對沒有那麼可靠,但消耗的資源少,傳輸速度快。TCP因要保證服務的可靠性,有自己的一套規則(三次握手),因爲這套規則的存在,使得TCP比UDP的消耗大,傳輸速度較慢。二協議沒有好壞之分,在不同的場合,各自有自己的妙用。

打個比方,你在網絡上買了一件商品,現在等待商家把貨物送到自己手裏。TCP協議就像這樣:

買家商家我這兩天在家,可以送貨。貨物已發送,請接收。貨物已收到,謝謝。買家商家

UDP協議呢,稍微簡單一些。大概是這樣的:

商家買家發貨商家買家

然後呢?結束了,對你沒有看錯,結束了,UDP協議只是確保數據發送了,至於你拿沒拿到,額…能力範圍之外。

還是那句話,打比方不要糾結在兩件事物的相似度上,只是藉助另一種事物對新學習的事物的某個特性從熟悉的側面進行描述。致此,對TCP和UDP中所謂的“可靠服務”就有了個大概瞭解。

文章介紹

在對一臺機器做安全測試的時候,我們一般會先對主機的端口(協議端口,也就是上面說的虛擬端口)進行掃描,看主機對外提供了哪些服務,然後根據開放的端口,開始對主機進行一個漏洞的找尋。

我們這片文章呢,就是實現一個簡易的端口掃描工具(Python實現),無實戰效果,旨在學習交流,當個玩具就好。其實現原理就是與每個端口建立TCP連接,如果與端口成功建立連接,就判定爲端口開放,根據開放的端口號,來猜測主機提供的服務。

因爲是個簡易的玩具,有很多的缺陷。比如,有時候端口建立連接的失敗,恰恰說明端口開放。哈?失敗了,端口還存在?舉個例子。

主機上裝有防火牆,對3306端口進行了保護,請求建立連接的數據包發送到主機後,防火牆發現外部數據要給3306端口,直接將數據包沒收(丟棄)。這種情況,連接是不能夠被成功建立的,但防火牆的這種保護行爲,恰恰說明了那個端口是開放狀態。

如果有玩安全的小夥伴,我們知道端口掃描工具裏面,有個比較知名成熟的工具——Nmap,使用過程中,最讓人頭疼就是它裏面有各種各樣的參數,通過設置不同的參數,可以使用很多種方式對端口進行掃描,而且相應的判斷端口開放的策略也不相同。這也是Nmap的強大之處,可以在多種情況下發現開放端口,即使它受保護。

而我們將要實現的玩具,沒有這麼複雜的判別機制,只是用建立連接的成功與否來判斷端口是否開放。類如,你是大哥,想去兄弟家串門,想先讓小弟去看看兄弟在不在家:

兄弟派小弟前往確認在家,小弟返回大哥,二哥在家!兄弟

這是正常情況下,端口建立連接成功。建立不成功呢?類如:

兄弟派小弟前往沒人,小弟返回大哥,二哥家裏沒人!兄弟

還有一種情況:

兄弟派小弟前往小弟被砍:“啊~”兄弟

這種屬於小弟回報失敗,但家裏有人的情況。也就是端口連接建立失敗,但端口開放的情況。(在計算機網絡中,不同的情況都會有相應的返回信息做處理,過於深入,這裏我們不做區分。)

在我們接下來的實現裏呢,是不具有這種判斷能力的,所以稱爲玩具,這裏的“你”是個小弟沒回來就認爲家裏沒人的“憨憨”。

設計分析

正文開始(謝天謝地,廢話終於結束了~~)!先從屬性上分析,要對主機端口進行掃描,需要什麼條件呢?第一個,主機的IP,首先你得告訴我掃描誰。第二個,就是要告訴我掃描哪些端口,可以暫時將其類型定爲列表。還需要什麼呢?我希望它具有多線程機制,加快掃描速度。所以,我還需要指定多線程的數量。嗯,屬性分析就差不多了。

掃描器的行爲呢?首先,必須有一個啓動掃描器運行的行爲run。二來,我希望可以瞭解掃描器的運行時間get_time。最後呢,添加個花裏胡哨的東西,來個開場動畫,在掃描器運行開始的時候,先輸出個logo,因爲這樣子蠻(hen)酷(sao)的~。簡略分析後,我們可以得出:

PortScanner
+ host
+ thread
+ file
+ animation()
+ run()
+ get_time()

PS:這裏的變量命名可能不太好,如果將thread改爲thread_num,將file改爲ports或port_list可能在閱讀性上更佳一些。爲了和後面的代碼對應,而且代碼也不是很長,我就不再修改了,大家明白代表的什麼意思就好,我下次注意。

設計實現

無需多言,肯定從__init__函數開始寫起,需要初始化的屬性只有三個host,thread,file。這裏host是必須要給的,而thread和file的設計上,我們可以將它們設計爲默認參數。在用戶不給予線程數和掃描端口列表時,則採用單線程,默認端口列表進行掃描。如:

class PortScanner:
    
    def __init__(self, host, thread_num=1, file="PORT.txt"):
        """build scanner object"""
        self.host = host
        self.thread_num = thread_num
        self.file = self._read_file(file)

    def _read_file(self, file):
        """read the dictionary of port"""
        with open(file, "r") as f:
            return f.read().split()

(第一次用markdown編輯器,高亮有點怪~)
這裏對file的設計,沒有直接選擇讓用戶傳遞列表,而是採用直接讀取列表文件的方式,這樣會提高交互效率。

對文件的讀取,我們不希望外界直接調用它,所以方法名稱前加了下劃線來警示一下。(你可以選擇前端雙下劃線,這樣會觸發Python解釋器的改名機制,但仍舊不能防止外界訪問,有機會我們單單聊Python中的下劃線。這裏只需要知道,“私有”只是我們對用戶的說明,並不是技術上的限制,如果它硬要訪問,那就讓它訪問吧。)端口文件,我們計劃以下圖這種格式存在。
端口文件
ok,對象屬性搞定。開始編寫行爲吧,先編寫哪一個呢?嗯,最簡單的animation。在文章開頭的效果展示裏,掃描器一啓動會輸出一個“PortScanner”的logo圖標,還挺好看的。就先寫它吧,非常簡單

    def animation(self):
        """show a opening animation"""
        return r"""
__________              __   _________                                         
\______   \____________/  |_/   _____/ ____ _____    ____   ____   ___________ 
 |     ___/  _ \_  __ \   __\_____  \_/ ___\\__  \  /    \ /    \_/ __ \_  __ \
 |    |  (  <_> )  | \/|  | /        \  \___ / __ \|   |  \   |  \  ___/|  | \/
 |____|   \____/|__|   |__|/_______  /\___  >____  /___|  /___|  /\___  >__|   
                                   \/     \/     \/     \/     \/     \/       

        """

你可以設計自己的字符畫,這裏我給一個傳送門。可以嘗試下。

注意,這裏採用三引號(也叫多行註釋)來標明字符串,並開始用r說明不要給我轉義,避免字符畫輸出後亂七八糟。

有的小夥伴,可能會問,爲什麼不直接print,而要選擇return呢?這裏其實是爲了後期維護的時候方便。這個工具太小了,我們嘗試拿個比較大的項目來說,一般在類或函數內部設計時,不會在內部輸出提示信息,因爲這會給後期的調試帶來混亂,比如:

def method():
	print("Nothing")
print(method())

當我們發現method函數有些問題時,想要輸出一下method的返回值,這時會輸出Nothing和None兩個信息,就會對我們的判斷造成干擾。尤其在多人項目裏,如果大家都在方法裏面亂輸出,最後調試人員會苦不堪言。(後面馬上我就會自己打自己的臉,因爲我沒有找到更好的編寫方式…)

還剩兩個行爲,獲取時間肯定要在掃描器運行後了,所以來編寫核心部分吧——run。

簡單分析下,run需要按指定線程數創建多個線程,然後每個線程去“搶”端口列表裏的端口進行掃描。當列表爲空,且最後一個進程運行完畢後,整個run過程結束。(這裏也是我們計算時間的結束時間點。)

分層設計,run函數只需要創建指定數量的線程即可,至於掃描端口,那是線程的事,不是run的工作,於是:

def run(self):
        """create child threads and run scanner"""
        self._running_time = time.time()
        for i in range(self.thread_num):
            threading.Thread(target=self._sub_thread).start()

接下來寫線程要做的事,因爲也是內部方法,我們不希望外部隨意調用,同樣也加上下劃線。

簡單分析下,線程的工作是,首先檢測端口列表是否爲空,如果爲空結束就好。如果端口列表還有待掃描的端口,就獲取一個端口,嘗試建立連接,同時所選端口從列表裏刪除。仍舊分層來設計,子線程就是從列表裏拿端口,然後建立連接,連接本身是一個完整的功能,抽象出來,另外來寫。於是:

def _sub_thread(self):
        """get port from dictionary and try to connect"""
        while self.file:
            port = self.file.pop()
            self._connect(int(port))

這裏使用列表的pop函數,免去我們的手動刪除,需要注意獲取的端口類型轉換。

接下來看與端口建立連接的方法connect,仍舊加上下劃線,原因同上。

def _connect(self, port):
        """establish connection with corresponding port"""
        sk = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        sk.settimeout(0.05)
        if not sk.connect_ex((self.host, port)):
                print("{} is alive.".format(port))
        sk.close()

這裏用socket編程,超時時間可以根據自己掃描的主機去設置,如果掃描遠程主機就適當把時間設置的大一些。這裏注意一點,我們可以用socket對象的connect方法來建立連接,用異常處理失敗。如:

    sk = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    sk.settimeout(0.05)
    try:
        sk.connect((ip, int(port)))
        print(port," is alive")
    except:
        pass
    finally:
        sk.close()

但是一般情況下,我們用異常處理未預期的或者不符合規則的運行時錯誤,而這裏的情況,因爲是對端口的掃描,大部分端口是處於關閉狀態,我們是能夠知道在大部分情況下連接的建立都是失敗的。所以這裏使用了connect_ex函數,這個函數與connect有點區別,就是連接成功的情況下,返回數值0,失敗返回非0數值。

這裏還有一個打臉的地方,就是儘量不要在方法或者函數內部做輸出。然而,我們需要工具實時的輸出當前的掃描結果,如果先將掃描結果保存,最後一起輸出的話,達不到這種效果,因此無奈就在內部輸出了。(你可以想辦法解決這個問題哦)

現在,我們編寫完了大部分的邏輯代碼,整合出來看看:

class PortScanner:

    def __init__(self, host, thread_num=1, file="PORT.txt"):
        """build scanner object"""
        self.host = host
        self.thread_num = thread_num
        self.file = self._read_file(file)

    def _read_file(self, file):
        """read the dictionary of port"""
        with open(file, "r") as f:
            return f.read().split()

    def animation(self):
        """show a opening animation"""
        return r"""
__________              __   _________                                         
\______   \____________/  |_/   _____/ ____ _____    ____   ____   ___________ 
 |     ___/  _ \_  __ \   __\_____  \_/ ___\\__  \  /    \ /    \_/ __ \_  __ \
 |    |  (  <_> )  | \/|  | /        \  \___ / __ \|   |  \   |  \  ___/|  | \/
 |____|   \____/|__|   |__|/_______  /\___  >____  /___|  /___|  /\___  >__|   
                                   \/     \/     \/     \/     \/     \/       

        """

    def run(self):
        """create child threads and run scanner"""
        self._running_time = time.time()
        for i in range(self.thread_num):
            threading.Thread(target=self._sub_thread).start()

    def _sub_thread(self):
        """get port from dictionary and try to connect"""
        while self.file:
            port = self.file.pop()
            self._connect(int(port))

    def _connect(self, port):
        """establish connection with corresponding port"""
        sk = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        sk.settimeout(0.05)
        if not sk.connect_ex((self.host, port)):
                print("{} is alive.".format(port))
        sk.close()

嗯,大體上還可以,就是覺得connect函數怪怪的,每有一個端口都要重新建立一個socket對象去連接,可不可以讓每個線程自己維護一個socket對象,一勞永逸,讓之後的端口也用它進行連接呢?這樣就省去了很多創建對象又close的時間。可以~不過…

這裏要注意下哈,socket是Python的標準庫,它封裝了最底層的套接字,它的具體實現由操作系統決定。如果是Linux操作系統,上述想法完全沒有問題。如果是Windos操作系統,則每個socket對象只能與一個端口建立連接,如果之後還要用此對象連接其他端口,都是失敗的(有點像打電話,後面再有人撥通,就是佔線)。如:

import socket

s = socket.socket()
ip = 'localhost'
for i in range(65536):
	s.connect_ex((ip, i))
s.close()

這段代碼在Linux和Windows上都不會報錯,只是Linux上得到的是預期效果,而Windows上只有第一個端口的情況是正確的,其後所有的端口建立連接都會失敗。

我這裏是WIn,假如是Linux,我們可以考慮怎麼設計來減少創建socket對象的時間。如:

def _sub_thread(self):
        """get port from dictionary and try to connect"""
        s = socket.socket()
        while self.file:
            port = self.file.pop()
            if not sk.connect_ex((self.host, port)):
                print("{} is alive.".format(port))
        s.close()

這裏就可以捨去connect的編寫,直將將其整合到sub_thread裏面。如此,在整個工具運行期間,socket建立的次數與進程數相同,節約了不少時間。(但是函數的邏輯上就稍微有些混亂,兩個函數合併成了一個,算是一種取捨吧。)

說道了這裏,我們可不可以只用一個socket對象,每個線程都用這個socket對象與自己拿到的端口建立連接呢?換而言之,把socket對象當做對象的一個屬性,每次調用它來連接端口。

額,我沒有試,因爲有問題。每個線程都用一個socket對象,同時進行連接,一個socket對象能同時對兩個端口建立連接嗎?或者說,同時連接兩個端口會發生什麼?這個大家自己去試吧,總之邏輯上是行不通的。即使socket本身的設計會將兩個端口排好序一個個掃描,不會引發錯誤,但請思考一個問題,這和單線程還有什麼區別?

好,因爲一個socket具體實現的不同引發了一些問題。我們回到原來的地方,仍舊以Win爲例,不對socket對象的創建進行修改。已經差不多了,剩下最後一個行爲,獲取掃描器的運行時間。

分析,運行時間的計算應該從哪裏開始到哪裏結束呢?肯定不是程序運行開始,把用戶輸入數據的時間計算在內就太荒誕了。綜合考慮,我們應該在run建立線程的時候就開始計時,當所有線程運行完畢後才停止計時。如此,開始和結束沒有在一個方法裏面,我們設置一個類變量保存時間。如:

class PortScanner:

    _number_of_threads_completed = 0
    _running_time = 0

    def run(self):
        """create child threads and run scanner"""
        self._running_time = time.time()
        for i in range(self.thread_num):
            threading.Thread(target=self._sub_thread).start()

    def _sub_thread(self):
        """get port from dictionary and try to connect"""
        while self.file:
            port = self.file.pop()
            self._connect(int(port))
        self._number_of_threads_completed += 1
        if self._number_of_threads_completed == self.thread_num:
            self._running_time = time.time() - self._running_time)

(隱藏其他不重要代碼)因爲結束的標誌是所有線程完成任務,所以我們加了個計數器,來算當前已完成的線程數(1、對類變量的引用可以使用cls.name的方式,這樣在可讀性上更優,上面是通過對象本身引用的。2、加下劃線原因同上)。

這之後,我們給獲取時間提供一個對外的接口就好了。如

def get_time():
	return self._running_time

啊,大功告成了~ 長舒一口氣,但是你不知道,犯了個不易被發現的錯誤,你在測試程序的時候纔會發現不太對勁。你會發現運行時間很奇怪,但是不知道爲什麼。

因爲到能夠運行測試還有一段距離,所以我們就直接說這個問題了哈。我們可能會採用如下的方式,對類進行使用:

if __name__ == "__main__":
    scanner = PortScanner(parser.host, parser.thread, parser.file)
    print(scanner.animation())
    scanner.run()
    print(scanner.get_time())

看似毫無破綻,但我們調用get_time的時候,掃描器真的運行結束了嗎?

在調用時間函數前,調用了run函數,run做了什麼呢?創建了若干個線程,當創建完成後,run的工作就完成了,這時就會執行get_time獲取運行時間,但這時候線程執行完了嗎?

創建
修改
run
若干線程
get_time
time
返回

要注意,線程執行完畢後纔會修改運行時間的參數time,而run執行完畢後,get_time直接獲取了time參數,而沒有關注線程是否已經執行完畢。所以大部分時間你會獲得一個代表當前時間的時間戳,而不是真正的運行時間。

因此,對外提供接口的方式失敗了。可以嘗試將時間計算的功能,分配到各個函數裏(線程函數),如:

class PortScanner:

    _number_of_threads_completed = 0
    _running_time = 0
    
    def __init__(self, host, thread_num=1, file="PORT.txt"):
        """build scanner object"""
        self.host = host
        self.thread_num = thread_num
        self.file = self._read_file(file)

    def _read_file(self, file):
        """read the dictionary of port"""
        with open(file, "r") as f:
            return f.read().split()

    def animation(self):
        """show a opening animation"""
        return r"""
__________              __   _________                                         
\______   \____________/  |_/   _____/ ____ _____    ____   ____   ___________ 
 |     ___/  _ \_  __ \   __\_____  \_/ ___\\__  \  /    \ /    \_/ __ \_  __ \
 |    |  (  <_> )  | \/|  | /        \  \___ / __ \|   |  \   |  \  ___/|  | \/
 |____|   \____/|__|   |__|/_______  /\___  >____  /___|  /___|  /\___  >__|   
                                   \/     \/     \/     \/     \/     \/       

        """

    def run(self):
        """create child threads and run scanner"""
        self._running_time = time.time()
        for i in range(self.thread_num):
            threading.Thread(target=self._sub_thread).start()

    def _sub_thread(self):
        """get port from dictionary and try to connect"""
        while self.file:
            port = self.file.pop()
            self._connect(int(port))
        self._number_of_threads_completed += 1
        if self._number_of_threads_completed == self.thread_num:
            print("Cost time {} seconds.".format(time.time() - self._running_time))

    def _connect(self, port):
        """establish connection with corresponding port"""
        sk = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        sk.settimeout(0.05)
        if not sk.connect_ex((self.host, port)):
                print("{} is alive.".format(port))
        sk.close()

在子線程全部結束時,直接計算時間並做輸出。有兩個缺點,一、儘量不要在函數內部做輸出(又打臉);二、函數邏輯變得更加混亂、冗長,子線程函數裏除了對端口進行掃描外,又多了一條計算時間的功能,它不再單純了。

致此,掃描器的類算是全部寫完了吧。雖然長的不那麼精緻,但是還是比較可靠的。

接下來考慮從命令行獲取參數的設計,你可以選擇用sys,然後用列表操作。我這裏採用argparse,給個傳送門,argparse要比sys靈活的多,相信我,你會喜歡上它的。

從命令行獲取的參數,只需要三個host,thread,file,而且只有host是必須的,另外兩個我們有默認參數,可給可不。如:

def create_parser():
    """accept command line arguments"""
    parser = argparse.ArgumentParser(description="The scanner of host port")
    parser.add_argument("host", help="Target host")
    parser.add_argument("-t", "--thread", help="The number of threads", \
                        type=int, choices=[1, 3, 5], default=1)
    parser.add_argument("-f", "--file", help="The name of file", \
                        type=str, default="PORT.txt")
    args = parser.parse_args()
    return args

對於線程數thread參數,我們給予了選擇空間1,3,5,你可以自定義設置。對於argparse的使用不在贅述,感興趣可以點擊傳送門查閱。

好了,結束了,撒花,鞠躬下臺。完整節目單:

import threading
import time
import socket
import argparse

class PortScanner:

    _number_of_threads_completed = 0
    _running_time = 0
    
    def __init__(self, host, thread_num=1, file="PORT.txt"):
        """build scanner object"""
        self.host = host
        self.thread_num = thread_num
        self.file = self._read_file(file)

    def _read_file(self, file):
        """read the dictionary of port"""
        with open(file, "r") as f:
            return f.read().split()

    def animation(self):
        """show a opening animation"""
        return r"""
__________              __   _________                                         
\______   \____________/  |_/   _____/ ____ _____    ____   ____   ___________ 
 |     ___/  _ \_  __ \   __\_____  \_/ ___\\__  \  /    \ /    \_/ __ \_  __ \
 |    |  (  <_> )  | \/|  | /        \  \___ / __ \|   |  \   |  \  ___/|  | \/
 |____|   \____/|__|   |__|/_______  /\___  >____  /___|  /___|  /\___  >__|   
                                   \/     \/     \/     \/     \/     \/       

        """

    def run(self):
        """create child threads and run scanner"""
        self._running_time = time.time()
        for i in range(self.thread_num):
            threading.Thread(target=self._sub_thread).start()

    def _sub_thread(self):
        """get port from dictionary and try to connect"""
        while self.file:
            port = self.file.pop()
            self._connect(int(port))
        self._number_of_threads_completed += 1
        if self._number_of_threads_completed == self.thread_num:
            print("Cost time {} seconds.".format(time.time() - self._running_time))

    def _connect(self, port):
        """establish connection with corresponding port"""
        sk = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        sk.settimeout(0.05)
        if not sk.connect_ex((self.host, port)):
                print("{} is alive.".format(port))
        sk.close()

def create_parser():
    """accept command line arguments"""
    parser = argparse.ArgumentParser(description="The scanner of host port")
    parser.add_argument("host", help="Target host")
    parser.add_argument("-t", "--thread", help="The number of threads", \
                        type=int, choices=[1, 3, 5], default=1)
    parser.add_argument("-f", "--file", help="The name of file", \
                        type=str, default="PORT.txt")
    args = parser.parse_args()
    return args

if __name__ == "__main__":
    parser = create_parser()
    scanner = PortScanner(parser.host, parser.thread, parser.file)
    print(scanner.animation())
    scanner.run()

關於GIL

我猜測有小夥伴可能會問道GIL的問題,也可能有些小夥伴根本沒聽過這個名詞。首先,GIL(Global Interpreter Lock 全局解析鎖)的規則是,所有訪問Python對象的線程都會被一個全局所串行化。概念的出現是出於線程安全,爲了進行保護。

各個線程之間沒有順序的隨意進行交互,就可能造成混亂。

比如我們最開始設計的get_time,它獲取的時間時,線程任務可能還沒執行完畢,這時獲取的時間就是錯誤數據。而錯誤產生的原因就是因爲get time需要的數據和其他程序的執行結果有關,執行結果會直接影響到get time。爲了避免這種事情的發生,我就可以強制將他們順序執行,等線程函數執行完了再執行get time,即所謂的將線程串行化。

注意,我只是拿這個例子說明爲什麼線程會不安全哈,不是說我們的這個例子本身會被GIL糾正。它是保證底層線程級的安全,你可以用我們文中的這個例子來思考爲什麼線程會不安全。

也因爲GIL概念的引入,如果一個線程中僅包含純Python代碼,那麼多線程毫無意義,因爲會被串行化,也就是會被順序執行。但注意,GIL只是強制在任何時候只有一個線程可執行Python代碼。在許多阻塞系統的調用或者是C擴展部分GIL會被釋放。換句話而言,多個線程可以執行I/O操作或在第三方擴展中並行執行C代碼。

回頭看我們的程序,爲什麼多線程會有效果?

def _sub_thread(self):
        """get port from dictionary and try to connect"""
        while self.file:
            port = self.file.pop()
            self._connect(int(port))
        self._number_of_threads_completed += 1
        if self._number_of_threads_completed == self.thread_num:
            print("Cost time {} seconds.".format(time.time() - self._running_time))

    def _connect(self, port):
        """establish connection with corresponding port"""
        sk = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        sk.settimeout(0.05)
        if not sk.connect_ex((self.host, port)):
                print("{} is alive.".format(port))
        sk.close()

猜猜看,哪個步驟最費時間?沒錯,就是sk.connect_ex((self.host, port)),其實線程的大部分時間在等待端口建立的返回結果。如果是單線程執行,走到這裏時,就會用大量的時間來等待連接成功與否的返回結果。而如果採用多線程,走到這裏時,在等待結果的過程中把CPU(或者理解成時間片)讓出來,讓其他線程建立新的連接,等這邊有了結果再回來。如此,就把等待的時間用來建立新的連接,速度就會加快。

在這裏插入圖片描述

GIL的引入,讓我們的多線程成了併發,而不是並行。我們感覺不到原因,是因爲時間片在各線程間來回切換。

以上是多線程起作用的一種情況,還有一種可以用到多線程的情況,就是用戶交互、提供響應界面的時候。

比如,用tkinter寫了個小窗口,一個按鈕點擊後,會獲取一個網頁的源碼。我們知道獲取源碼的過程,相對於其它操作來說,消耗的時間是非常巨大的。而桌面窗口的顯示是一個不停止的循環,當你點擊按鈕後,線程就會跑去獲取網頁,那這裏的窗口循環誰來幹呢?沒人了,所以界面就會卡死,或者稱爲“假死”也行。

這時,就可以採用多線程,讓獲取網頁的邏輯單一的成爲一個線程,讓窗口循環和獲取網頁併發的進行即可。

多線程的本意是並行,即多個線程同時進行(需要依靠多核)。但GIL的引入,使得並行成了併發,即CPU在多個線程間來回切換着執行,因爲CPU執行速度過快,對於我們“凡人”來說,就和多個線程同時執行效果一樣。而站在CPU的角度看,其實在同一時間內,只有一個線程在運行。

綜上,在這些情況下,我們不用考慮GIL,因爲併發和並行對我們普通用戶的效果近乎於相同,但是你要了解清楚併發和並行的區別。

最後,注意GIL不是Python語言的特性,而是Python具體實現時設計的規則。我們常說的Python,實爲CPython,即核心代碼由C語言設計,其他的例如Stackless Python和PyPy等具體的實現方式都不太相同,而Jython和IronPython的實現中就沒有GIL這個概念。

就如前面說的,HTML的規則制定完了,至於對這個文本文件怎麼去解析,那是各個瀏覽器廠商的事情。Python語法規則制訂完了,你的解釋器怎麼解析這個文本代碼,要看解釋器具體的實現。這裏我們也可以理解,爲什麼同一種語言會有那麼多的編譯器和解釋器了。

打個比方,我畫了一座城堡的設計圖。你用沙子堆了一個,對面那哥們用鋼筋水泥建了一座。你們兩個都是對我這張圖紙(規則)的具體實現,但是他那個城堡,沒有“怕水”這個特性,但是你的有。這就是你的具體實現方式本身產生的特性。

當然了,CPython早就提出了刪除GIL的主題,不過還沒有提出相對合理的方案,或許在等你吧。

我也不知道怎麼幾十行代碼的東西,我能洋洋灑灑寫了近兩萬字,希望你看到這裏,能夠覺得沒有浪費你的時間。(下篇嘗試實現簡易的網站目錄掃描器,時間未定。)

完。

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