【问题标题】:Sharing python objects across multiple workers在多个工作人员之间共享 python 对象
【发布时间】:2021-01-12 14:51:10
【问题描述】:

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

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

下面的脚本显示了我们正在做的(简化的)示例,尽管在我们的例子中 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


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

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

那么问题来了,我们如何让 worker 共享和操作同一个对象?

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

谢谢!

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

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

【问题讨论】:

    标签: python asynchronous python-asyncio fastapi


    【解决方案1】:

    在不同进程之间直接共享一个 python 对象是不可能的。 multiprocessing 模块中包含的设施(如 managers共享内存)不适合在工作人员之间共享资源,因为它们需要一个主进程来创建资源并执行不具备耐久属性。服务器进程也可以在不同的机器上运行。

    首选在工作人员之间共享资源的方式:

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

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

    测试项目结构如下:

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

    Docker 编写文件:

    对于实验,您可以使用以下 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,
    )
    

    【讨论】:

    • 我使用 cache = cache(Cache.MEMORY) 在 gunicorn worker 之间共享数据。它不起作用,一个工作人员无法访问另一个工作人员写入缓存的数据。
    【解决方案2】:

    如果您使用带有 gunicorn 和 uvicorn 的设置运行您的 FastAPI 服务,如docs 中所述,您可以以更简单的方式使用方法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 进行比较,以查看无论哪个工作进程是它都会返回递增的值回应。

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

    【讨论】:

      【解决方案3】:

      您可以创建架构,而无需任何外部库或添加任何额外的数据库复杂性等。

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

      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. 您可能想要添加类似正常关闭的内容。因为这是一个非常简单但高度可扩展的示例。

      【讨论】:

        【解决方案4】:

        问题 1

        那么问题来了,我们如何让 worker 共享和操作同一个对象?

        TL;DR

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

        说明

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

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

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

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

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


        问题 2

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

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

        【讨论】:

          猜你喜欢
          • 2019-07-02
          • 2017-03-02
          • 1970-01-01
          • 2013-04-12
          • 2017-03-28
          • 2019-02-12
          • 2012-02-03
          • 2017-08-23
          • 2012-07-22
          相关资源
          最近更新 更多