翻译: Python 中的异步任务执行(Asynchronous Task Execution In Python)

原文链接

https://bhavaniravi.com/blog/asynchronous-task-execution-in-python

参考

https://sspai.com/post/46912

翻译

调度器是一段漂亮的代码。 在计算机系统中,该算法和操作系统一样古老。在现实世界中,则和我们的闹钟一样古老。 在计算机世界中,程序员过去常常与操作员(一个工作人员) 约定一个工作时间来运行他们的代码。 后来,当程序员想让他们的程序脱离操作员执行时,他们编写了调度算法。

当操作系统出现在画面上时, 调度程序取代了计算机操作员,将程序输入 CPU 执行。 多年来,随着处理核数的增加, 调度算法的复杂性也增加了。硬件缓存层、RAM层和硬盘层提出了对不同类型的长、中、短期调度的需要(这句不太理解想表达的意思)。

现在,在云和分布式系统的时代, 调度器已经成为任何软件系统中不可缺少的架构组件。 这些异步任务执行器隐藏在电子邮件、通知、登录时的弹出窗口以及发送到电子邮件的报告等等后面。

Celery、RabbitMQ、Redis、谷歌任务队列 API 和 Amazon 的 SQS 是分布式环境中任务调度的主流参与者。

本博客的其余部分将阐明传统的任务队列系统以及asyncio的位置,最后我们将介绍主要参与者的优缺点。
在这里插入图片描述

传统任务调度

传统的任务队列有两个程序(生产者和消费者), 以数据库充当其中的队列。
对于生产者创建的每个任务,都会在数据库中创建一个带有 NotStarted, Running, Completed, Failed 等标志的条目。

在任何时候,任务的工作者(比如一个永无止境的 python 程序)都会去查询中这个数据库,查找到未完成的任务并开始运行它。

这是一个有缺点的简单实现。

缺点

  • 在 DB 表上维护任务意味着表会根据任务的数量增长。当数据库增长如此之多以至于我们不得不处理扩展问题时,情况就变得复杂了。
  • 对于每一个空闲的消费者,都会使用任务标记系统来查询数据库,以获取一个它可以运行的调度任务。随着数据库大小的增长,查询的成本会变得很高。

Corn

Corn 是允许你在给定的时间异步运行任务的最简单的软件应用程序。
该应用程序维护一个名为 crontab 的文件表。该实用程序本身是一个每分钟运行一次的计划作业,它获取在当前分钟运行的每个命令的日志, 并运行每个命令。多酷啊 ?

  • 可变性
  • 备份
  • 清理临时文件
  • 提醒

Example

我编写了一个简单的 python 脚本来触发Mac通知,要求我每20分钟休息一次。

import os


def notify(title, text):
    os.system(""" 
    osascript -e 'display notification "{}" with title "{}"'
    """.format(text, title))


notify("Take a break", "You are sitting for too long")

运行该程序,mac 会向我们发送一个通知。
(关于 osascript 语言,参见 https://sspai.com/post/46912 )

然后我们在 crontab 中将其设置为每 20 分钟执行一次的定时任务:

# > crontab -e
*/20 * * * * python /<path_to_script>/notfication.py

需要注意的问题

  1. 如果用户跨越时区怎么办?当我们处理时间的时候,我们也在处理时区的问题。Cron 作业默认在运行它的机器的时区配置中运行,我们可以使用 TZ 环境变量覆盖它。但是,如果我们想在不同的时区运行不同的任务,cron 就不适合了。
  2. 如果 cron 失败会发生什么?当脚本执行 cron 作业失败时,只会记录错误,cron 等待下一个调度。这不是最可靠的处理错误的方法。我们通常希望调度器重试,直到达到某个阈值后才继续执行。
  3. 可伸缩性——如果在给定时间内要执行的任务数量非常大怎么办?-如果一个任务占用了大量内存怎么办?

代码示例-寻宝

我将用一个寻宝程序作为例子来解释博客中的概念。问题陈述很简单。

我们有一个寻宝程序,有10000个文件,其中一个文件包含一个单词宝藏。

这个程序的目标是检查这些文件并找到宝藏。

异步Python

异步 Python 在 asyncio 发布后越来越受欢迎。虽然它与任务调度器没有任何关系,但了解它的位置是很重要的。

线程

Python 线程是一个古老的故事。虽然它提供了同时运行多个线程的概念,但实际上并没有。为什么? 因为 cpython 有 GIL(全局解释器锁)。除非您的程序有很多等待外部(I/O)事件,否则使用线程没有多大用处。即使您的笔记本电脑有多个核,您也经常会发现由于GIL,它们在 CPU 密集型任务上处于空闲状态。
使用线程实现的 treasure ure_hunter 程序会让线程查看不同范围的文件。

import random
import threading
import time

N = 100
treasure_found = False


def creating_treasure():
    """
    Creates N files with treasure randomly set in one of the files
    """
    # 模拟随机出一个藏有宝藏的文件编号
    treasure_in = random.randint(0, N)
    print("treasure in: ", treasure_in)
    for i in range(0, N):
        print(i)
        with open(f"treasure_data/file_{i}.txt", "w") as f:
            if i != treasure_in:
                # f.writelines(["Not a treasure\n"] * N)
                f.writelines(["Not a treasure\n"])
            else:
                # f.writelines(["Treasure\n"] * N)
                f.writelines(["Treasure\n"])
    print(f"treasure is in {treasure_in}")


def find_treasure(start, end):
    print(f"{start}, {end}")
    global treasure_found
    for i in range(start, end):
        if treasure_found:
            return

        with open(f"treasure_data/file_{i}.txt", "r") as f:
            if f.readlines()[0] == "Treasure\n":
                treasure_found = i
                return


def threading_run():
    # 线程数量
    num_of_threads = 10
    # 每个线程分配的文件数
    count = int(N / num_of_threads)
    start_time = time.time()
    threads = [threading.Thread(target=find_treasure, args=[i, i + count]) for i in range(0, N, count)]
    [thread.start() for thread in threads]
    [thread.join() for thread in threads]

    print("--- %s seconds ---" % (time.time() - start_time))
    print(f"Found treasure {treasure_found}")


if __name__ == '__main__':
    creating_treasure()

    threading_run()

多进程

多进程模块使我们能够克服线程化的缺点。要理解这一点,最简单的方法是GIL 只应用于线程而不应用于进程,从而为我们提供了一种实现并行的方法。

多进程在 CPU 密集型任务上也工作得很好,因为我们可以独立使用所有可用的内核。在设计多处理问题时,进程通常共享一个队列,每个进程都可以从该队列加载任务以便下一次执行。

import logging
import time
from random import randint
from multiprocessing import Process

N = 100
treasure_found = False


def creating_treasure():
    """
    Creates N files with treasure randomly set in one of the files
    """
    treasure_in = randint(1, N)
    print(treasure_in)
    for i in range(0, N):
        print(i)
        with open(f"treasure_data/file_{i}.txt", "w") as f:
            if i != treasure_in:
                f.writelines(["Not a treasure\n"])
            else:
                f.writelines(["Treasure\n"])
    print(f"treasure is in {treasure_in}")


def find_treasure(start, end):
    logging.debug(f"{start}, {end}")
    global treasure_found
    for i in range(start, end):
        if treasure_found:
            return

        with open(f"treasure_data/file_{i}.txt", "r") as f:
            if f.readlines()[0] == "Treasure\n":
                treasure_found = i
                return


def process_run():
    num_of_process = 100
    start_time = time.time()

    processes = [
        Process(target=find_treasure, args=[i, int(i + N / num_of_process)])
        for i in range(0, N, int(N/num_of_process))
    ]

    [process.start() for process in processes]
    [process.join() for process in processes]

    print("--- %s seconds ---" % (time.time() - start_time))
    print(f"Found treasure {treasure_found}")


if __name__ == '__main__':

    creating_treasure()

    process_run()

Asyncio

在上面的两个例子中,线程或进程之间的切换是由 CPU 处理的。在某些情况下,开发人员可能更清楚何时应该在 CPU 上进行上下文切换。它不是让进程或线程来切换,而是让程序(开发人员)决定什么时候程序可以停止去执行其他任务。

import asyncio
from random import randint

N = 100
treasure_found = False


def creating_treasure():
    """
    Creates N files with treasure randomly set in one of the files
    """
    treasure_in = randint(1, N)
    print("treasure in: ", treasure_in)
    for i in range(0, N):
        with open(f"treasure_data/file_{i}.txt", "w") as f:
            if i != treasure_in:
                f.writelines(["Not a treasure\n"])
            else:
                f.writelines(["Treasure\n"])
    print(f"treasure is in {treasure_in}")


async def read_file(i):
    global treasure_found
    with open(f"treasure_data/file_{i}.txt", "r") as f:
        if f.readlines()[0] == "Treasure\n":
            treasure_found = i
            return


async def find_treasure(start, end):
    global treasure_found
    for i in range(start, end):
        if treasure_found:
            return
        # Await until file is read
        await read_file(i)


async def main():
    num_of_threads = 10
    count = int(N / num_of_threads)
    tasks = [find_treasure(i, i + count) for i in range(0, N, count)]
    await asyncio.gather(*tasks)


if __name__ == '__main__':
    creating_treasure()

    asyncio.run(main())

celery

celery 是一种基于分布式消息传递的异步任务队列/任务队列。它专注于实时操作,但也支持调度。

celery 有两个主要的组件 Queue 和 worker.

Queue,也称为 message_brokers,是一个队列系统,您可以在其中推送要异步执行的任务。

Worker 按照一定的频率 ping 队列并执行任务。

Message Broker

你可能经常混淆术语 Redis,celelry 和 RabbitMQ。

前面提到的这些队列组件并不是内置在 celery 中的。

celery 使用了 RabbitMq 或 Redis 队列系统。这就是为什么你经常发现文章提到这些。

Worker

当你启动一个 celery worker 时, 它将会创建一个监控进程, 反过来带动一些其他的执行器。 以上被称之为执行池。

可以被一个 celery worker 执行的任务数取决于执行池中的进程数。
在这里插入图片描述

Scheduling Tasks in Celery

和 crontab 不同, celery 默认情况下不会安排任务在特定的时间运行。 为了支持任务调度, celery 可启用 celery beat。

**当任务失败时会发生什么呢?**当特定任务失败时,您可以配置 celery 重试,直到出现特定异常为止,或者设置max_retries 参数,在放弃之前启用 n 次重试。

Idempodency(幂等性)

假设您的任务是将 N 个 item 备份到数据库中。如果两个worker 拿起任务并执行它,那么调用函数应该确保同一个条目不会在 DB 中出现两次。worker 对执行某项任务的副作用一无所知(意思是 worker 不能确保以上效果)。

# 原文
Let's say you are backing up N-items into a DB as your task. In case two workers pick up the tasks and execute it then it is for the calling function to make sure that the same entry is not made in a DB twice. Workers will have no clue about the side effects of running a particular task.

Redis队列

Redis 默认是内存数据库,仅此而已。RQ(Redis queue)是一个异步执行任务的任务调度器,它使用 Redis 的队列数据结构,具有内置的worker实现。它的结构和工作原理celery 非常相似。

N = 10000  
num_of_process = 10  
count = int(N/num_of_process)  
  
start_time = time.time()  
q = Queue(connection=Redis())  
results = [q.enqueue(find_treasure, i, i+count)  
           for i in range(0, N, count)]

RQ vs. Celery

  • 如果有一天你醒来决定改变你的排队系统呢, Celery支持大量的消息代理,但 RQ 只用于与 Redis 一起工作。

  • celery 支持子任务。RQ没有。

  • RQ 与优先级队列一起工作,您可以配置 worker 处理具有特定优先级的任务。在 celery 中,实现这一点的唯一方法是将这些任务路由到不同的服务器。

  • RQ 只适用于python,celery 不是。

  • celery 支持预定的工作。

  • RQ只在支持 fork() 的系统上运行。最值得注意的是,这意味着如果不使用 Windows Subsystem for Linux 和开启一个 bash shell,就不可能在 Windows 上运行 worker。如果你正在使用 windows,RQ 不适合你。

后记

后续的对比内容暂时不需要。

对原文中的代码进行了补全和校正。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值