动手实现简易端口扫描器——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的主题,不过还没有提出相对合理的方案,或许在等你吧。

我也不知道怎么几十行代码的东西,我能洋洋洒洒写了近两万字,希望你看到这里,能够觉得没有浪费你的时间。(下篇尝试实现简易的网站目录扫描器,时间未定。)

完。

  • 3
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值