跨多个worker共享python对象

时间:2021-01-12 14:51:10

标签: python asynchronous python-asyncio fastapi

我们使用 FastAPI 创建了一个服务。当我们的服务启动时,它会创建一些 Python 对象,然后端点使用这些对象来存储或检索数据。

生产中的 FastAPI 从多个工人开始。我们的问题是每个worker创建自己的对象而不是共享一个

下面的脚本显示了我们正在做的(简化的)示例,尽管在我们的例子中 Meta() 的使用要复杂得多。

from fastapi import FastAPI, status

class Meta:
   def __init__(self):
      self.count = 0  

app = FastAPI()

meta = Meta()

# increases the count variable in the meta object by 1
@app.get("/increment")
async def increment():
    meta.count += 1
    return status.HTTP_200_OK

# returns a json containing the current count from the meta object
@app.get("/report")
async def report():
    return {'count':meta.count}

# resets the count in the meta object to 0
@app.get("/reset")
async def reset():
    meta.count = 0
    return status.HTTP_200_OK


如上所述,多个 worker 的问题是每个 worker 都有自己的 meta 对象。请注意,当使用单个工作器运行 api 时,该问题不可见。

更明确地说,当我们第一次点击 /increment 端点时,我们只会看到两个工作人员中的一个响应调用(这是正确的,我们不希望两个工作人员都做同样的事情) .但是,因为有两个单独的 meta 对象,所以只有两个对象之一会增加。
当点击 /report 端点时,根据哪个工作线程响应请求,将返回 1 或 0。

接下来的问题是,我们如何让工作进程共享和操作同一个对象?

作为一个附带问题,上述问题也会影响 /reset 端点。如果调用此端点,则只有一个工作人员将重置其对象。有没有办法强制所有工作人员响应端点上的单个呼叫?

谢谢!

编辑:我忘了提到我们已经尝试(但没有成功)将 meta 对象存储在 app.state 中。本质上:

app.state.meta = Meta()
...
@app.get("/report")
async def report():
    return {'count':app.state.meta.count}

4 个答案:

答案 0 :(得分:4)

无法直接在不同进程之间共享 Python 对象。 multiprocessing 模块中包含的设施(如 managersshared memory)不适合在 worker 之间共享资源,因为它们需要一个主进程来创建资源并且没有耐久性属性。

最喜欢的方法是在worker之间共享资源:

  • 数据库 - 在需要可靠存储和可扩展性的资源的持久性的情况下。示例:PostgreSQLMariaDBMongoDB 等。
  • 缓存(键/值) - 在数据的临时性质的情况下,比数据库更快,但没有这种可扩展性,并且通常不符合 ACID。示例:RedisMemcached 等。

下面我将展示两个非常简单的示例,说明如何使用这两种方法在工作人员之间共享 FastAPI 应用程序中的数据。例如,我以 aiocache 作为后端的 Redis 库和 Tortoise ORM 作为后端的 PostgreSQL 库。由于 FastAPI 是异步框架,我选择了基于 asyncio 的库。

测试项目的结构如下:

.
├── app_cache.py
├── app_db.py
├── docker-compose.yml
├── __init__.py

Docker-compose 文件:

对于实验,您可以使用以下 docker-compose 文件将 5432 (Postgres) 和 6379 (Redis) 端口暴露给 localhost

version: '3'

services:
  database:
    image: postgres:12-alpine
    ports:
      - "5432:5432"
    environment:
      POSTGRES_PASSWORD: test_pass
      POSTGRES_USER: test_user
      POSTGRES_DB: test_db
  redis:
    image: redis:6-alpine
    ports:
      - "6379:6379"

开始:

docker-compose up -d

缓存 (aiocache)

<块引用>

Aiocache 提供了 3 个主要实体:

  • 后端:允许您指定要用于缓存的后端。目前支持:SimpleMemoryCacheRedisCache 使用 aioredisMemCache 使用 aiomcache
  • serializers:序列化和反序列化您的代码和后端之间的数据。这允许您将任何 Python 对象保存到缓存中。目前支持:StringSerializerPickleSerializerJsonSerializerMsgPackSerializer。但您也可以构建自定义的。
  • 插件:实现一个钩子系统,允许在每个命令之前和之后执行额外的行为。

开始:

uvicorn app_cache:app --host localhost --port 8000 --workers 5
# app_cache.py
import os
from aiocache import Cache
from fastapi import FastAPI, status


app = FastAPI()
cache = Cache(Cache.REDIS, endpoint="localhost", port=6379, namespace="main")


class Meta:
    def __init__(self):
        pass

    async def get_count(self) -> int:
        return await cache.get("count", default=0)

    async def set_count(self, value: int) -> None:
        await cache.set("count", value)

    async def increment_count(self) -> None:
        await cache.increment("count", 1)


meta = Meta()


# increases the count variable in the meta object by 1
@app.post("/increment")
async def increment():
    await meta.increment_count()
    return status.HTTP_200_OK


# returns a json containing the current count from the meta object
@app.get("/report")
async def report():
    count = await meta.get_count()
    return {'count': count, "current_process_id": os.getpid()}


# resets the count in the meta object to 0
@app.post("/reset")
async def reset():
    await meta.set_count(0)
    return status.HTTP_200_OK

数据库(Tortoise ORM + PostgreSQL)

开始: 为了简单起见,我们首先运行一个worker在数据库中创建一个schema:

uvicorn app_db:app --host localhost --port 8000 --workers 1
[Ctrl-C] 
uvicorn app_db:app --host localhost --port 8000 --workers 5
# app_db.py
from fastapi import FastAPI, status
from tortoise import Model, fields
from tortoise.contrib.fastapi import register_tortoise


class MetaModel(Model):
    count = fields.IntField(default=0)


app = FastAPI()


# increases the count variable in the meta object by 1
@app.get("/increment")
async def increment():
    meta, is_created = await MetaModel.get_or_create(id=1)
    meta.count += 1  # it's better do it in transaction
    await meta.save()
    return status.HTTP_200_OK


# returns a json containing the current count from the meta object
@app.get("/report")
async def report():
    meta, is_created = await MetaModel.get_or_create(id=1)
    return {'count': meta.count}


# resets the count in the meta object to 0
@app.get("/reset")
async def reset():
    meta, is_created = await MetaModel.get_or_create(id=1)
    meta.count = 0
    await meta.save()
    return status.HTTP_200_OK

register_tortoise(
    app,
    db_url="postgres://test_user:test_pass@localhost:5432/test_db",  # Don't expose login/pass in src, use environment variables
    modules={"models": ["app_db"]},
    generate_schemas=True,
    add_exception_handlers=True,
)

答案 1 :(得分:4)

如果您按照 docs 中的说明使用带有 gunicorn 和 uvicorn 的设置运行 FastAPI 服务,则可以更简单地使用方法 described here by Yagiz Degimenci。您可以将 gunicorn 的 --reload 设置与 multiprocessing.Manager 结合使用,以避免启动另一台服务器的必要性。特别是以下内容不需要额外的设置就可以在单个 Docker 容器中工作。

import logging
from multiprocessing import Manager

manager = Manager()

store = manager.dict()

store["count"] = 0

from fastapi import FastAPI

app = FastAPI()


@app.post("/increment")
async def increment():
    store["count"] = store["count"] + 1


@app.get("/count")
async def get_count():
    return store["count"]


@app.on_event("startup")
async def startup_event():
    uv_logger = logging.getLogger("uvicorn.access")
    handler = logging.StreamHandler()
    handler.setFormatter(
        logging.Formatter(
            "%(process)d - %(processName)s - %(asctime)s - %(levelname)s - %(message)s"
        )
    )
    uv_logger.addHandler(handler)

将其保存为 demo.py 并通过以下方式运行(您需要 fastapi、guvicorn 和 uvicorn 库):

GUNICORN_CMD_ARGS="--bind=127.0.0.1 --workers=3 --preload --access-logfile=-" gunicorn -k uvicorn.workers.UvicornWorker demo:app

--preload 在这里必不可少!)

尝试通过 http://localhost:8000/docs 上的 OpenApi UI 进行递增,并将对 /count 端点的多次调用与访问日志输出中的进程 ID 进行比较,以查看它返回递增的值,而不管哪个工作进程是回应。

注意:我不在这里声明任何关于线程/异步安全的声明,并且这种方法可能不应该在生产服务中使用。如有任何疑问,您应该始终依靠适当的数据库/缓存/内存存储解决方案进行生产设置。我自己只在演示代码中使用它!

答案 2 :(得分:3)

问题 1

<块引用>

接下来的问题是,我们如何让工作进程共享和操作同一个对象?

TL;DR

虽然您可以通过 multiprocessing 之类的方式共享对象,但在您的用例中,您可能最好使用缓存,例如 Redis。

说明

我根本不是并行/并发应用程序方面的专家,但我知道除非您需要加速非常昂贵的 CPU 密集型操作(即非常复杂和/或长时间运行的计算),您不要希望在进程之间共享对象。

您可以通过专用库和模块做到这一点,但它会使您的应用变得更加复杂,必须处理所有可能的竞争条件和并行性内在的边缘情况。如果您确实想走那条路,我相信有很多库和工具,但您应该首先查看 multiprocessing,这是用于处理并行性的标准 Python 库。另请检查 thisthis 以使用它与 gunicorn 的工作人员共享资源。

另一方面,您的用例看起来并不需要非常复杂的计算,因此我建议使用简单的缓存作为工作人员的“数据中心”,而不是类。它将为您提供所需的结果,让您的流程拥有单一的真实来源,而无需共享内存的复杂性。

如果您想尝试这种方法,我建议您看看 Redis,这是一种非常流行且支持良好的缓存解决方案,如果您愿意,甚至可以保留数据。

这是 Redis clients for python 的列表。 redis-py 是推荐的。


问题 2

<块引用>

作为一个附带问题,上述问题也会影响 /reset 端点。如果调用此端点,则只有一个工作人员将重置其对象。有没有办法强制所有工作人员响应端点上的单个呼叫?

如果您使用缓存,问题就会消失。你只有一个真实的来源,你只需删除那里的数据,无论哪个工人响应请求。然后每个工人都会看到数据已被重置。

答案 3 :(得分:2)

您可以在不需要任何外部库或使用数据库等添加任何额外复杂性的情况下创建架构。

这将是我们跨不同进程共享对象的服务器。

from multiprocessing.managers import SyncManager


class MyManager(SyncManager):
    pass

syncdict = {}

def get_dict():
    return syncdict

if __name__ == "__main__":
    MyManager.register("syncdict", get_dict)
    manager = MyManager(("127.0.0.1", 5000), authkey=b"password")
    manager.start()
    input()
    manager.shutdown()

将此文件命名为 server.py 并在不同的进程上运行它。只需 python server.py 就可以了。

让我们跳到我们的客户端实现。

这将是我们的客户端实现。

from multiprocessing.managers import SyncManager
from typing import Optional, Dict, Any, Union


class MyManager(SyncManager):
    ...


class Meta:
    def __init__(self, *, port: int) -> None:
        self.manager = MyManager(("127.0.0.1", port), authkey=b"password")
        self.manager.connect()
        MyManager.register("syncdict")

        self.syndict = self.manager.syncdict()

    def update(self, kwargs: Dict[Any, Any]) -> None:
        self.syndict.update(kwargs)

    def increase_one(self, key: str) -> None:
        self.syndict.update([(key, self.syndict.get(key) + 1)])

    def report(self, item: Union[str, int]) -> int:
        return self.syndict.get(item)


meta = Meta(port=5000)

让我们将其与我们的 API 合并。

from fastapi import FastAPI, status

from multiprocessing.managers import SyncManager
from typing import Optional, Dict, Any, Union


class MyManager(SyncManager):
    ...


class Meta:
    def __init__(self, *, port: int, **kwargs: Dict[Any, Any]):
        self.manager = MyManager(("127.0.0.1", port), authkey=b"password")
        self.manager.connect()
        MyManager.register("syncdict")

        self.syndict = self.manager.syncdict()
        self.syndict.update(**kwargs)

    def increase_one(self, key: str):
        self.syndict.update([(key, self.syndict.get(key) + 1)])

    def reset(self, key: str):
        self.syndict.update([(key, 0)])

    def report(self, item: Union[str, int]):
        return self.syndict.get(item)


app = FastAPI()

meta = Meta(port=5000, cnt=0)

# increases the count variable in the meta object by 1
@app.get("/increment")
async def increment(key: str):
    meta.increase_one(key)
    return status.HTTP_200_OK


# returns a json containing the current count from the meta object
@app.get("/report")
async def report(key: str):
    return {"count": meta.report(key)}


# resets the count in the meta object to 0
@app.get("/reset")
async def reset(key: str):
    meta.reset(key)
    return status.HTTP_200_OK

我要启动两个 API 实例,一个在 8000 上,另一个在 8001 上。

In: curl -X GET "http://127.0.0.1:8000/report?key=cnt"
Out: {"count": 0}

In: curl -X GET "http://127.0.0.1:8001/report?key=cnt"
Out: {"count": 0}

两者都以 0 值开始。现在让我们增加它

for _ in {1..10}; do curl -X GET "http://127.0.0.1:8000/increment?key=cnt" &; done

我在端口 8000 上运行 curl 10 次,这意味着 cnt 应该是 10。

让我们从端口 8001 进行检查:

In: curl -X GET "http://127.0.0.1:8001/report?key=cnt" 
Out: {"cnt": 10}

像魅力一样工作。

需要考虑两件事。

  1. 您应该在不同的进程中启动您的应用。更具体地说,uvicorn my_app:app 和您的服务器不应是父进程。
  2. 您可能想要添加诸如优雅关机之类的功能。因为这是一个非常简单但高度可扩展的示例。
相关问题