最近做项目的时候,自己写了个脚本用来测试漏洞信息,但是因为开始写的是单线程的,所以效率比较低,想要优化一下脚本,所以总结一下并发的相关知识。
无线程版本
首先创建一个简单的程序用来显示效果:
import time
port_list = []
for i in range(10):
port_list.append(i)
for i in port_list:
print(i)
time.sleep(1)
向list中添加1-10,并且等待一秒,用时间来判断效率有没有提升。
可以看到单线程下用了10秒。
加入threading
threading库可用来在单独的线程中执行任意Python可调用对象,我们首先使用threading模块的Thread类来创建一个线程,先要创建一个实例,在传给它一个函数,最后开启线程,代码如下:
import time
import threading
port_list = []
threads = []
def printf(i):
print(i)
time.sleep(1)
def main():
for i in range(10):
port_list.append(i)
for i in port_list:
t = threading.Thread(target=printf,args=(i,))
threads.append(t)
for t in threads:
t.start()
while True:
if(len(threading.enumerate())<100):
break
if __name__ == '__main__':
main()
这里我们将输出写成了一个函数,将需要输出的值通过传参的方式传给函数,然后通过while循环和threading.enumerate()来控制线程数。
我们看到在建立了10个线程的情况下,代码运行仅需要1秒左右。
这里在漏斗社区发的文章可以看到,文章还对利用pop方法传参和join方法对线程使用for循环控制进行了优化。
Lock
如果多个线程访问同一个资源,就可能产生无法预期的效果。
多线程和多进程最大的不同在于,多进程中,同一个变量,各自有一份拷贝存在于每个进程中,互不影响,而多线程中,所有变量都由所有线程共享,所以,任何一个变量都可以被任何一个线程修改,因此,线程之间共享数据最大的危险在于多个线程同时改一个变量,把内容给改乱了。
我们引用廖雪峰中的代码看一下效果:
import time, threading
# 假定这是你的银行存款:
balance = 0
def change_it(n):
# 先存后取,结果应该为0:
global balance
balance = balance + n
balance = balance - n
def run_thread(n):
for i in range(100000):
change_it(n)
t1 = threading.Thread(target=run_thread, args=(5,))
t2 = threading.Thread(target=run_thread, args=(8,))
t1.start()
t2.start()
t1.join()
t2.join()
print(balance)
我们定义了一个共享变量balance,初始值为0,并且启动两个线程,先存后取,理论上结果应该为0,但是,由于线程的调度是由操作系统决定的,当t1、t2交替执行时,只要循环次数足够多,balance的结果就不一定是0了。
这是由于高级语言的一条语句在CPU执行时是若干条语句,即使一个简单的计算:
balance = balance + n
也分两步:
- 计算balance + n,存入临时变量中;
- 将临时变量的值赋给balance。
也就是可以看成:
x = balance + n
balance = x
由于x是局部变量,两个线程各自都有自己的x,当代码正常执行时:
初始值 balance = 0
t1: x1 = balance + 5 # x1 = 0 + 5 = 5
t1: balance = x1 # balance = 5
t1: x1 = balance - 5 # x1 = 5 - 5 = 0
t1: balance = x1 # balance = 0
t2: x2 = balance + 8 # x2 = 0 + 8 = 8
t2: balance = x2 # balance = 8
t2: x2 = balance - 8 # x2 = 8 - 8 = 0
t2: balance = x2 # balance = 0
结果 balance = 0
但是t1和t2是交替运行的,如果操作系统以下面的顺序执行t1、t2:
初始值 balance = 0
t1: x1 = balance + 5 # x1 = 0 + 5 = 5
t2: x2 = balance + 8 # x2 = 0 + 8 = 8
t2: balance = x2 # balance = 8
t1: balance = x1 # balance = 5
t1: x1 = balance - 5 # x1 = 5 - 5 = 0
t1: balance = x1 # balance = 0
t2: x2 = balance - 8 # x2 = 0 - 8 = -8
t2: balance = x2 # balance = -8
结果 balance = -8
是因为修改balance需要多条语句,而执行这几条语句时,线程可能中断,从而导致多个线程把同一个对象的内容改乱了。所以,我们必须确保一个线程在修改balance的时候,别的线程一定不能改。因此要加入锁的机制来防止这种事情的发生。
import time, threading
# 假定这是你的银行存款:
balance = 0
lock = threading.Lock()
def change_it(n):
# 先存后取,结果应该为0:
global balance
balance = balance + n
balance = balance - n
def run_thread(n):
for i in range(100000):
# 先要获取锁:
lock.acquire()
try:
# 放心地改吧:
change_it(n)
finally:
# 改完了一定要释放锁:
lock.release()
t1 = threading.Thread(target=run_thread, args=(5,))
t2 = threading.Thread(target=run_thread, args=(8,))
t1.start()
t2.start()
t1.join()
t2.join()
print(balance)
这次就不会有之前的问题发生:
当多个线程同时执行lock.acquire()时,只有一个线程能成功地获取锁,然后继续执行代码,其他线程就继续等待直到获得锁为止。
获得锁的线程用完后一定要释放锁,否则那些苦苦等待锁的线程将永远等待下去,成为死线程。所以我们用try...finally来确保锁一定会被释放。
锁的好处就是确保了某段关键代码只能由一个线程从头到尾完整地执行,坏处当然也很多,首先是阻止了多线程并发执行,包含锁的某段代码实际上只能以单线程模式执行,效率就大大地下降了。其次,由于可以存在多个锁,不同的线程持有不同的锁,并试图获取对方持有的锁时,可能会造成死锁,导致多个线程全部挂起,既不能执行,也无法结束,只能靠操作系统强制终止。
避免死锁的一种解决方案就是给程序中的每个锁分配一个唯一的数字编号,并且获得多个锁的时候只需按照编号升序来获取。可以利用上下文管理器实现。
import threading
from contextlib import contextmanager
_local = threading.local
@contextmanager
def acqurie(*locks):
locks = sorted(locks,key=lambda x:id(x))
acquried = getattr(_local,'acquried',[])
if acquried and max(id(lock) for lock in acquried) >= id(locks[0]):
raise RuntimeError('Lock order Violation')
acquried.extend(locks)
_local.acquried = acquried
try:
for lock in locks:
lock.acqurie()
yield
finally:
for lock in reversed(locks):
lock.release
del acquried[-len(locks):]
这样只用按照正常的方式来分配锁对象,但是当想同一个或多个锁打交道时就用acquire函数,例如:
import threading
x_lock = threading.Lock()
y_lock = threading.Lock()
def thread_1():
while True:
with acquire(x_lock, y_lock):
print('Thread-1')
def thread_2():
while True:
with acquire(y_lock, x_lock):
print('Thread-2')
t1 = threading.Thread(target=thread_1)
t1.daemon = True
t1.start()
t2 = threading.Thread(target=thread_2)
t2.daemon = True
t2.start()
多线程和队列
Queue是python标准库中的线程安全的队列(FIFO)实现,提供了一个适用于多线程编程的先进先出的数据结构,即队列,用来在生产者和消费者线程之间的信息传递(python3中的队列模块是queue,不是Queue)
import queue
q = queue.Queue()
for i in range(10):
q.put(i)
while not q.empty():
print(q.get())
我们看一下结果:
Queue模块中的常用方法:
Queue.qsize() 返回队列的大小
Queue.empty() 如果队列为空,返回True,反之False
Queue.full() 如果队列满了,返回True,反之False
Queue.full 与 maxsize 大小对应
Queue.get([block[, timeout]])获取队列,timeout等待时间
Queue.get_nowait() 相当Queue.get(False)
Queue.put(item) 写入队列,timeout等待时间
Queue.put_nowait(item) 相当Queue.put(item, False)
Queue.task_done() 在完成一项工作之后,Queue.task_done()函数向任务已经完成的队列发送一个信号
Queue.join() 实际上意味着等到队列为空,再执行别的操作
我们可以看到get()方法从队列移除并返回一个数据,取出操作可以放在不同的线程中,不会出现同步的问题。
我们经常会遇到这样的一个问题,这里有成千上万条数据,每次需要取出其中的一条数据进行处理,那么引入多线程该怎么进行任务分配?
我们可以将数据进行分割然后交给多个线程去跑,可是这并不是一个明智的做法。在这里我们可以使用队列与线程相结合的方式进行任务分配。
队列线程的思想: 首先创建一个全局共享的队列,队列中只存在有限个元素,并将所有的数据逐条加入到队列中,并调用队列的join函数进行等待。之后便可以开启若干线程,线程的任务就是不断的从队列中取数据进行处理就可以了。
线程间通信最安全的做法就是使用queue,首先创建一个queue实例,他会被所有的线程共享,之后可以使用put()或get()给队列添加删除元素。queue已经拥有了所有所需的锁,所以可以安全的在任意多的线程之间共享。
import time
import queue
import threading
def printf(q):
while not q.empty():
print(threading.current_thread().name)
print(q.get())
time.sleep(1)
q.task_done()
def main():
q = queue.Queue()
for i in range(100):
q.put(i)
for i in range(10):
t = threading.Thread(target=printf,args=(q,))
t.setDaemon(True)
t.start()
q.join()
print('End')
if __name__ == '__main__':
main()
我们构造一个一百个数字的队列,看一下都是哪个线程在完成任务:
漏洞社区的文章中还设置了守护线程(daemon),首先知道当我们创建了一个线程实例方法时,在调用它的start()方法之前,线程不会立刻开始执行,线程实例会在他们所属的系统级线程(POSIX或者Windows)中执行,这些线程完全由操作系统来管理,一旦启动后,线程就开始独立运行,直到目标函数返回位置,当我们请求连接(join)到某个线程时,会等待该线程结束,解释器会一直保持运行,直到所有的线程结束,对于需要长时间运行或者一直不断运行的线程我们应该设置守护线程,daemon线程无法被连接,但是当主线程结束后他们会自动销毁。
多进程 VS 多线程
多进程/多线程:
表示可以同时执行多个任务,进程和线程的调度是由操作系统自动完成。
进程:每个进程都有自己独立的内存空间,不同进程之间的内存空间不共享。
进程之间的通信有操作系统传递,导致通讯效率低,切换开销大。
线程:一个进程可以有多个线程,所有线程共享进程的内存空间,通讯效率高,切换开销小。
共享意味着竞争,导致数据不安全,为了保护内存空间的数据安全,引入"互斥锁"。
一个线程在访问内存空间的时候,其他线程不允许访问,必须等待之前的线程访问结束,才能使用这个内存空间。
互斥锁:一种安全有序的让多个线程访问内存空间的机制。
Python的多线程:
GIL 全局解释器锁:线程的执行权限,在Python的进程里只有一个GIL。
一个线程需要执行任务,必须获取GIL。
好处:直接杜绝了多个线程访问内存空间的安全问题。
坏处:Python的多线程不是真正多线程,不能充分利用多核CPU的资源。
但是,在I/O阻塞的时候,解释器会释放GIL。
所以:
多进程:密集CPU任务,需要充分使用多核CPU资源(服务器,大量的并行计算)的时候,用多进程。 multiprocessing
缺陷:多个进程之间通信成本高,切换开销大。
多线程:密集I/O任务(网络I/O,磁盘I/O,数据库I/O)使用多线程合适。
threading.Thread、multiprocessing.dummy
缺陷:同一个时间切片只能运行一个线程,不能做到高并行,但是可以做到高并发。
协程:又称微线程,在单线程上执行多个任务,用函数切换,开销极小。不通过操作系统调度,没有进程、线程的切换开销。genvent,monkey.patchall
多线程请求返回是无序的,那个线程有数据返回就处理那个线程,而协程返回的数据是有序的。
缺陷:单线程执行,处理密集CPU和本地磁盘IO的时候,性能较低。处理网络I/O性能还是比较高.
异步IO
我们已经知道,CPU的速度远远快于磁盘、网络等IO。在一个线程中,CPU执行代码的速度极快,然而,一旦遇到IO操作,如读写文件、发送网络数据时,就需要等待IO操作完成,才能继续进行下一步操作。这种情况称为同步IO。
在IO操作的过程中,当前线程被挂起,而其他需要CPU执行的代码就无法被当前线程执行了。
因为一个IO操作就阻塞了当前线程,导致其他代码无法执行,所以我们必须使用多线程或者多进程来并发执行代码,为多个用户服务。每个用户都会分配一个线程,如果遇到IO导致线程被挂起,其他用户的线程不受影响。
多线程和多进程的模型虽然解决了并发问题,但是系统不能无上限地增加线程。由于系统切换线程的开销也很大,所以,一旦线程数量过多,CPU的时间就花在线程切换上了,真正运行代码的时间就少了,结果导致性能严重下降。
由于我们要解决的问题是CPU高速执行能力和IO设备的龟速严重不匹配,多线程和多进程只是解决这一问题的一种方法。
另一种解决IO问题的方法是异步IO。当代码需要执行一个耗时的IO操作时,它只发出IO指令,并不等待IO结果,然后就去执行其他代码了。一段时间后,当IO返回结果时,再通知CPU进行处理。
协程由于具有极高的执行效率,因为子程序的切换不是线程切换,而是程序自身控制,所以没有线程切换的开销,所以线程越多协程性能优势越明显,而且不需要锁机制,因为只有一个线程,也不存在写变量冲突,为了利用多核cpu,可以采用多进程+协程,充分发挥协程的高效率,也可以利用多核,可以获得极高的性能。
脚本优化
我本身的脚本是通过post对某网站进行用户名密码验证,现打算用多进程 + 协程进行并发优化。
由于requests库提供的相关方法不是可等待对象(awaitable),使得无法放在await后面,因此无法使用requests库在协程程序中实现请求,在此,官方专门提供了一个aiohttp库,用来实现异步网页请求等功能,详情请参阅官方文档。
我又被客户爸爸ban了,等我从小黑屋里放出来再更新吧。
参考链接
- https://www.freebuf.com/news/162019.html
- https://www.liaoxuefeng.com/wiki/0014316089557264a6b348958f449949df42a6d3a2e542c000
- https://www.jianshu.com/p/c2b80ab5e501
- https://blog.csdn.net/SL_World/article/details/86633611
- https://blog.csdn.net/vspiders/article/details/80724583
- https://www.cnblogs.com/huangguifeng/p/7632799.html
- https://blog.csdn.net/qq_31720329/article/details/82023393
- https://luca-notebook.readthedocs.io/zh_CN/latest/c01/%E7%94%A8aiohttp%E5%86%99%E7%88%AC%E8%99%AB.html
- 《Python CookBook》