分类: Python 爬虫

  • Python Scrapy 爬虫框架

    Scrapy 相关细节

    • 安装scrapy框架
      • pip install scrapy -i https://pypi.douban.com/simple
    • 创建scrapy爬虫
      • 进入项目文件夹
        • cd project_name/project_name/spiders
      • 运行命令
        • scrapy startproject project_name url
        • D:\Practice\Python\Scrapy_20230226\scrapy_carhome\scrapy_carhome\spiders> scrapy genspider car https://car.autohome.com.cn/price/brand-15.html
    • 运行爬虫
      • 进入当前爬虫所在文件夹
        • scrapy crawl spider_name
        • D:\Practice\Python\Scrapy_20230226\scrapy_carhome\scrapy_carhome\spiders> scrapy crawl car

     

     

     

    Scrapy 数据流程

    https://docs.scrapy.org/en/latest/topics/architecture.html

    1. 引擎得到来自于Spider的url请求
    2. 引擎调用调度器将待爬取url放入列队中
    3. 调度器返回请求给引擎
    4. 引擎发送请求给下载器
    5. 下载器获取互联网资源, 获取数据后返回给引擎
    6. 引擎收到来自下载器的响应, 将其发送给Spider, 以进行处理
    7. Spider返回处理过的数据给引擎(同时也发送下一个新的URL给引擎)
    8. 引擎发送处理过的数据给Pipelines, 以保存数据
    9. 重复第三步之后的步骤, 直到没有新的任务

     

     

    The data flow in Scrapy is controlled by the execution engine, and goes like this:

    1. The Engine gets the initial Requests to crawl from the Spider.
    2. The Engine schedules the Requests in the Scheduler and asks for the next Requests to crawl.
    3. The Scheduler returns the next Requests to the Engine.
    4. The Engine sends the Requests to the Downloader, passing through the Downloader Middlewares (see process_request()).
    5. Once the page finishes downloading the Downloader generates a Response (with that page) and sends it to the Engine, passing through the Downloader Middlewares (see process_response()).
    6. The Engine receives the Response from the Downloader and sends it to the Spider for processing, passing through the Spider Middleware (see process_spider_input()).
    7. The Spider processes the Response and returns scraped items and new Requests (to follow) to the Engine, passing through the Spider Middleware (see process_spider_output()).
    8. The Engine sends processed items to Item Pipelines, then send processed Requests to the Scheduler and asks for possible next Requests to crawl.
    9. The process repeats (from step 3) until there are no more requests from the Scheduler.

     

     

  • 什么是CPython GIL?

    什么是Python GIL?

    什么是解释器?

    Python作为一门解释性语言,先把源代码编译为字节码,再放进虚拟机中执行,整个过程是由解释器执行并完成的。类似的还有JavaScript和PHP等。

    但是解释器并不是只有一种,官方的解释器是基于C语言开发的CPython。但是除了CPython,还有基于Java实现的Jython、基于R 语言实现的RPython等等。

     

     

     什么是GIL?

    GIl 是一种互斥锁

    什么是互斥锁?

    在编程中,引入了对象互斥锁的概念,来保证共享数据操作的完整性。每个对象都对应于一个可称为” 互斥锁” 的标记,这个标记用来保证在任一时刻,只能有一个线程访问该对象。

     

    当多个线程几乎同时修改某一个共享数据的时候,需要进行同步控制。

    线程同步能够保证多个线程安全访问竞争资源,最简单的同步机制是引入互斥锁。

    互斥锁为资源引入一个状态:锁定/非锁定

    某个线程要更改共享数据时,先将其锁定,此时资源的状态为“锁定”,其他线程不能更改;直到该线程释放资源,将资源的状态变成“非锁定”,其他的线程才能再次锁定该资源。互斥锁保证了每次只有一个线程进行写入操作,从而保证了多线程情况下数据的正确性。

     

    为什么会产生GIL?

    GIL的产生是因为CPython的内存管理不安全

    In CPython, the global interpreter lock, or GIL, is a mutex that protects access to Python objects, preventing multiple threads from executing Python bytecodes at once. The GIL prevents race conditions and ensures thread safety. A nice explanation of how the Python GIL helps in these areas can be found here. In short, this mutex is necessary mainly because CPython’s memory management is not thread-safe。

    在CPython中,GIL是一个互斥锁,它在任一时刻只允许一个线程对字节码进行执行。这样避免了竞争危害,从而保证了线程安全。简单来说就是,互斥锁之所以存在是因为CPython的内存管理不是“线程安全的”

    由此Python的官方文档可知, 因为因为CPython的内存管理不是“线程安全的”, 所以需要互斥锁, 那么自然会引出另外两个问题。

    GIL的产生是因为CPython的内存管理不安全

    为什么CPython的内存管理不安全?

    Python 第一次发布是在 1991 年,当时的 CPU 都是单核,单核中,多线程主要为了一边做IO,一边做 CPU 计算而设计的,Python 编译器是由 C 语言编写的,因此也叫 CPython,那时候很多编程语言没有自动内存管理的功能,为了实现自动垃圾回收,Python 为每一个对象进行了引用计数,当引用计数为 0 的时候说明该对象可以回收,从而释放内存了,比如:

    >>> import sys
    >>> a = []
    >>> b = a
    >>> sys.getrefcount(a)
    3

    这里 a对象就有 3 个引用,

    • 一个是本身,
    • 一个是变量 b,
    • 一个是 getrefcount 函数的参数,

    如果此时又有一个线程引用了 a,那么引用计数再增加 1,如果某个线程使用了 a 后运行结束,那么引用计数就减少 1,多线程对同一个变量「引用计数」进行修改,就会遇到 race conditions(竞争)。

     

    怎么解决内存管理不安全的问题?

    为了避免 race conditions,最简单有效的办法就是加一个互斥锁。但如果对每一个对象都加锁,有可能引发另一个问题,就是死锁,而且频繁的获取和释放会导致性能下降。

    所以至此, 最简单有效的方法就是加一个解释器锁,线程在执行任何字节码时都先获取解释器锁,这就避免了死锁,而且不会有太多的性能消耗。当时 CPU 都是单核,而且这种 GIL 设计简单,并不会影响性能,因此一直沿用至今天。GIL 存在最主要的原因,就是因为 Python 的内存管理不是线程安全的,这就是 GIL 产生并存在的主要缘由。

    互斥锁的代码实例

    threading模块中定义了Lock类,可以方便的处理锁定:

     # 创建锁
     mutex = threading.Lock()
     ​
     # 锁定
     mutex.acquire()
     ​
     # 释放
     mutex.release()
    • 如果这个锁之前是没有上锁的,那么acquire不会堵塞
    • 如果在调用acquire对这个锁上锁之前,它已经被 其他线程上了锁,那么此时acquire会堵塞,直到这个锁被解锁为止

     

    互斥锁在for循环外面
    import threading
    import time
    
    # 定义一个全局变量
    g_num = 0
    
    
    def test1(num):
     global g_num
     # 上锁,如果之前没有被上锁,那么此时 上锁成功
     # 如果上锁之前 已经被上锁了,那么此时会堵塞在这里,直到 这个锁被解开位置
     mutex.acquire()
     for i in range(num):
         g_num += 1
     mutex.release()   # 解锁
     print("-----in test1 g_num=%d----" % g_num)
    
    
    def test2(num):
     global g_num
     mutex.acquire()   # 上锁
     for i in range(num):
         g_num += 1
     mutex.release()   # 解锁
     print("-----in test2 g_num=%d=----" % g_num)
    
    
    # 创建一个互斥锁,默认是没有上锁的
    mutex = threading.Lock()
    
    
    def main():
     t1 = threading.Thread(target=test1, args=(1000000,))
     t2 = threading.Thread(target=test2, args=(1000000,))
    
     t1.start()
     t2.start()
    
     # 等待上面的2个线程执行完毕....
     time.sleep(2)
    
     print("-----in main Thread g_num = %d---" % g_num)
    
    if __name__ == "__main__":
     main()
    
    #-----in test1 g_num=1000000----
    #-----in test2 g_num=2000000=----
    #-----in main Thread g_num = 2000000---
    互斥锁在for循环里面
    import threading
    import time
    
    # 定义一个全局变量
    g_num = 0
    
    def test1(num):
     global g_num
     for i in range(num):
         mutex.acquire()  # 上锁
         g_num += 1
         mutex.release()  # 解锁
    
     print("---test1---g_num=%d"%g_num)
    
    def test2(num):
     global g_num
     for i in range(num):
         mutex.acquire()  # 上锁
         g_num += 1
         mutex.release()  # 解锁
    
     print("---test2---g_num=%d"%g_num)
    
    # 创建一个互斥锁
    # 默认是未上锁的状态
    mutex = threading.Lock()
    
    # 创建2个线程,让他们各自对g_num加1000000次
    p1 = threading.Thread(target=test1, args=(1000000,))
    p1.start()
    
    p2 = threading.Thread(target=test2, args=(1000000,))
    p2.start()
    
    # 等待计算完成
    while len(threading.enumerate()) != 1:
     time.sleep(1)
    
    print("2个线程对同一个全局变量操作之后的最终结果是:%s" % g_num)
    
    
    # ---test1---g_num=1909909
    # ---test2---g_num=2000000
    # 2个线程对同一个全局变量操作之后的最终结果是:2000000

     

    上锁解锁过程
    • 当一个线程调用锁的acquire()方法获得锁时,锁就进入“locked”状态。
    • 每次只有一个线程可以获得锁。如果此时另一个线程试图获得这个锁,该线程就会变为“blocked”状态,称为“阻塞”,直到拥有锁的线程调用锁的release()方法释放锁之后,锁进入“unlocked”状态。
    • 线程调度程序从处于同步阻塞状态的线程中选择一个来获得锁,并使得该线程进入运行(running)状态。
    锁的好处
    • 确保了某段关键代码只能由一个线程从头到尾完整地执行
    锁的坏处
    • 阻止了多线程并发执行,包含锁的某段代码实际上只能以单线程模式执行,效率就大大地下降了
    • 由于可以存在多个锁,不同的线程持有不同的锁,并试图获取对方持有的锁时,可能会造成死锁。

     

    死锁代码实例

     

    import threading
    import time
    
    
    #创建互斥锁
    lock = threading.Lock()
    
    
    #根据下标去取值, 保证同一时刻只能有一个线程去取值
    def get_value(index):
    
        # 上锁
        lock.acquire()
        print(threading.current_thread())
        my_list = [3,6,8,1]
        # 判断下标释放越界
        if index >= len(my_list):
            print("下标越界:", index)
    
            return
        value = my_list[index]
        print(f'值是:{value}')
        time.sleep(0.2)
        # 释放锁
        lock.release()
    
    
    if __name__ == '__main__':
        # 模拟大量线程去执行取值操作
        for i in range(30):
            sub_thread = threading.Thread(target=get_value, args=(i,))
            sub_thread.start()

    出现死锁的情况, 程序无法正常停止, 一直在等待

    <Thread(Thread-1, started 30364)>
    值是:3
    <Thread(Thread-2, started 27120)>
    值是:6
    <Thread(Thread-3, started 29632)>
    值是:8
    <Thread(Thread-4, started 29988)>
    值是:1
    <Thread(Thread-5, started 20984)>
    下标越界: 4
    

    避免死锁的代码示例

    # 在合适的地方释放锁
    import threading
    import time
    
    #创建互斥锁
    lock = threading.Lock()
    
    
    #根据下标去取值, 保证同一时刻只能有一个线程去取值
    def get_value(index):
    
        # 上锁
        lock.acquire()
        print(threading.current_thread())
        my_list = [3,6,8,1]
        if index >= len(my_list):
            print("下标越界:", index)
            # 当下标越界需要释放锁,让后面的线程还可以取值
            lock.release()
            return
        value = my_list[index]
        print(value)
        time.sleep(0.2)
        # 释放锁
        lock.release()
    
    
    if __name__ == '__main__':
        # 模拟大量线程去执行取值操作
        for i in range(10):
            sub_thread = threading.Thread(target=get_value, args=(i,))
            sub_thread.start()

     

    <Thread(Thread-1, started 30336)>
    3
    <Thread(Thread-2, started 5920)>
    6
    <Thread(Thread-3, started 28308)>
    8
    <Thread(Thread-4, started 27324)>
    1
    <Thread(Thread-5, started 26840)>
    下标越界: 4
    <Thread(Thread-6, started 30104)>
    下标越界: 5
    <Thread(Thread-7, started 28900)>
    下标越界: 6
    <Thread(Thread-8, started 2676)>
    下标越界: 7
    <Thread(Thread-9, started 28912)>
    下标越界: 8
    <Thread(Thread-10, started 30068)>
    下标越界: 9
    
    Process finished with exit code 0

     

    最后, GIL导致了什么结果?

    正面

    解决安全问题。

    负面

    单线程CPU消耗

    约16% (i5 11代)

     

    import threading
    
    def dead_loop():
        while True:
            pass
    dead_loop()

     

    双线程CPU消耗

    仍然约16%, 而不是32%。

    import threading
    
    def dead_loop():
        while True:
            pass
        # 新起一个死循环线程
        t = threading.Thread(target=dead_loop)
    
        t.start()
        # 主线程也进入死循环
        dead_loop()
        t.join()
    
    dead_loop()

     

    结论:

    • 当双线程时, Cpython 缩小好的CPU的资源, 和单线程时一致。
    • Cpython 当前只能运行一个GIL线程。

     

     

    如果再更进一步, 尝试十个或N个线程, Python的CPU利用率仍然不变。

    但是用C、C++或Java来改写相同的死循环,直接可以把全部核心跑满,为什么Python不行?正是GIL。

    Python的线程虽然是真正的线程,但解释器执行代码时,有一个GIL锁:Global Interpreter Lock,任何Python线程执行前,必须先获得GIL锁,然后,每执行100条字节码,解释器就自动释放GIL锁,让别的线程有机会执行。这个GIL全局锁实际上把所有线程的执行代码都给上了锁,所以,多线程在Python中只能交替执行,即使100个线程跑在100核CPU上,也只能用到1个核。

    在Python中,可以使用多线程,但不要指望能有效利用多核。如果一定要通过多线程利用多核,那只能通过C扩展来实现,不过这样就失去了Python简单易用的特点。

    不过,也不用过于担心,Python虽然不能利用多线程实现多核任务,但可以通过多进程实现多核任务。多个Python进程有各自独立的GIL锁,互不影响。

    其它

    • 对于 “Python的GIL” 这种表述是不够严谨, 但也不算错。
    • GIL是相对于Cpython 解释器而言, 而不是Python 语言。
    • Cpython是用来解析Python代码.
    • Cpython是目前最流行的, 主流的解释器.

     

    最后, 只要你愿意, 你可以自己开发一个没有GIL的解释器.