前言 之前在Python-gRPC实践 系列文章中都是在多线程模式中介绍如何使用gRPC
,但是在Python
生态中更偏好通过协程的方式来运行服务,而Python
的协程运行方式却有多种,虽然他们的原理类似,但是使用上却有区别,本文主要是对在gevent
和asyncio
中对gRPC
的使用进行对比。
1.简单的例子 本章以官方中的helloworld Protobuf
文件为例子,介绍在不同协程中如何运行服务,该Protobuf
文件内容如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 service Greeter { rpc SayHello (HelloRequest) returns (HelloReply) {} }message HelloRequest { string name = 1 ; int32 sleep_millisecond = 2 ; }message HelloReply { string message = 1;}
其中HelloRequest
和HelloReply
是Message
,Gretter
为service
,它们生成的对应代码位于grpc_asyncio_example/protos
目录下面,而对应的客户端代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 import loggingimport grpcfrom grpc_asyncio_example.protos import helloworld_pb2, helloworld_pb2_grpcdef client (target: str = "localhost:9999" ): with grpc.insecure_channel(target=target) as channel: stub: helloworld_pb2_grpc.GreeterStub = helloworld_pb2_grpc.GreeterStub(channel) response: helloworld_pb2.HelloReply = stub.SayHello(helloworld_pb2.HelloRequest(name='you' ), timeout=10 ) logging.info("Greeter client received: " + response.message)if __name__ == '__main__' : logging.basicConfig() client()
服务端代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 from concurrent import futuresimport timeimport loggingimport grpcfrom grpc_asyncio_example.protos import helloworld_pb2, helloworld_pb2_grpcclass Greeter (helloworld_pb2_grpc.GreeterServicer ): def SayHello ( self, request: helloworld_pb2.HelloRequest, context: grpc.ServicerContext ) -> helloworld_pb2.HelloReply: time.sleep(request.sleep_millisecond / 1000 ) return helloworld_pb2.HelloReply(message='Hello, %s!' % request.name)def serve (target: str = "localhost:9999" ) -> grpc.server: server: grpc.server = grpc.server(futures.ThreadPoolExecutor(max_workers=100 )) helloworld_pb2_grpc.add_GreeterServicer_to_server(Greeter(), server) server.add_insecure_port(target) server.start() return serverif __name__ == '__main__' : logging.basicConfig() serve().wait_for_termination()
客户端与服务端的代码都非常简单,客户端主要是发送一个请求,该请求参数为name=you
,而服务端则是解析参数,并把Hello {name}
返回给客户端,最终由客户端把服务端返回的消息打印出来。 这份代码只可以用于Python
的多线程中,无法在协程环境中运行,接下来讲分别介绍Python gRPC
在gevent
和asyncio
中的使用和原理分析。
只介绍UNARY_UNARY
请求方式,其他方式不同可以通过对应的文档了解如何使用。
2.Gevent gevent
是一个基于协程的Python
网络库,它通过greenlet
在libev
或libuv
事件循环之上提供高级同步API,在Python
程序使用gevent
很简单,只要在代码引入如下猴子补丁语法:
1 2 import gevent.monkey gevent.monkey.patch_all()
那么整个程序的Python
代码都支持gevent
了,程序也可以通过gevent
来运行。
不过Python gRPC
是个例外,因为Python gRPC
的核心是由C
语言编写的,而gevent
应用的猴子补丁只能涉及到用Python
语言编写的代码,所以单靠应用gevent
的猴子补丁是没办法使Python gRPC
在gevent
中运行的,可能还会导致调用gRPC
客户端方法时出现卡死的情况。
好在Python gRPC
提供了一个用法,使Python gRPC
能够在gevent
中兼容运行,代码如下:
1 2 3 4 5 6 7 8 9 10 11 import gevent.monkey gevent.monkey.patch_all()import grpc.experimental.gevent grpc.experimental.gevent.init_gevent() ...
这份代码通过调用grpc.experimental.gevent.init_gevent
,使gRPC
能在gevent
中运行,而且在gevent
模式下gRPC
的所有模块和方法的使用方式都不需要改变。 但是gRPC
在通过gevent
的兼容模式运行的时候,性能会大打折扣,比如通过下面的命令:
1 2 3 4 5 6 ghz -c 100 -n 10000 \ --insecure \ --proto ../protos/grpc_asyncio_example/protos/helloworld.proto \ --call helloworld.Greeter.SayHello \ -d '{"name":"Joe", "sleep_millisecond": 10}' \ localhost:9999
对gevent
模式下的Python gRPC
服务进行压测,其中c
是指并发数,n
为最大请求数,而-d
是请求的参数,里面的sleep_millisecond
代表服务端收到请求后会休眠10毫秒来模拟IO处理,他们的压测结果如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 Summary: Count: 10000 Total: 2.67 s Slowest: 45.01 ms Fastest: 12.04 ms Average: 26.34 ms Requests/sec: 3746.87 Summary: Count: 10000 Total: 5.23 s Slowest: 69.31 ms Fastest: 13.43 ms Average: 51.83 ms Requests/sec: 1913.44
通过压测结果可以发现,gevent
兼容模式的性能只有多线程模式下的一半(以Requests/sec
对比),会有这样的结果是因为Python gRPC
的异步请求是在多线程上执行,同时Python gRPC
是一个基于C
的库,它可以绕过Python
的GIL
,从而获得了CPU多核的并发能力,而在启用了gevent
兼容模式后gevent
可能会导致多线程无法绕过Python
的GIL
,从而失去了上面提到的并发性。
此外Python gRPC
目前只做到了兼容gevent
的运行,通过commands.py 中TestGevent
的注释可以看出Python gRPC
尚未对gevent
模式的性能做出优化,所以建议不要在Python gRPC
的服务端启用gevent
兼容模式,在Python gRPC
的客户端可以根据实际情况决定是否启用(比如在gunicorn
+ gevent
+ Flask
运行的服务中的Python gRPC
客户端)。
通过下列文件可以看出gevent
兼容模式是把线程池替换为gevent
的线程池:
3.Asyncio Python gRPC
一开始是不支持在Asyncio
中运行的,所以社区出现了一个名为grpclib 的库来解决这一问题,而官方的Python gRPC
在后面的迭代后诞生了grpc.aio
模块以支持grpc
在Asyncio
中运行。
3.1.grpclib grpclib 这个库提供了纯Python
的实现,这意味着能按自己的需求对它进行拓展,可塑性强。 所以诞生了基于grpclib 和pure-protobuf 封装的另一个库–python-betterproto
。该库可以使开发者编写grpc
代码更加的方便,比如helloworld 生成对应的Message
代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 @dataclass(eq=False , repr =False ) class HelloRequest (betterproto.Message ): """The request message containing the user's name.""" name: str = betterproto.string_field(1 ) sleep_millisecond: int = betterproto.int32_field(2 )@dataclass(eq=False , repr =False ) class HelloReply (betterproto.Message ): """The response message containing the greetings""" message: str = betterproto.string_field(1 )
可以看到这段代码打可读性比起官方的Python gRPC
要高很多,这样开发者在查阅message
的用法时会非常的方便,而客户端和服务端的用法则与官方的Python gRPC
类似,比如客户端的代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 import asyncioimport loggingfrom grpclib.client import Channelfrom grpc_asyncio_example.protos import helloworldasync def client () -> None : channel: Channel = Channel(host="localhost" , port=9999 ) response: helloworld.HelloReply = await helloworld.GreeterStub(channel).say_hello( hello_request=helloworld.HelloRequest(name="you" ) ) logging.info(response) channel.close()if __name__ == "__main__" : asyncio.run(client())
可以看出与多线程的Python gRPC
代码类似,而服务端的代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 import asynciofrom grpclib.server import Serverfrom grpc_asyncio_example.protos import helloworldclass HelloWorldService (helloworld.GreeterBase ): async def say_hello ( self, request: "helloworld.HelloRequest" ) -> "helloworld.HelloReply": await asyncio.sleep(request.sleep_millisecond / 1000 ) return helloworld.HelloReply(message="Hello, %s!" % request.name)async def serve () -> Server: server: Server = Server([HelloWorldService()]) await server.start("localhost" , 9999 ) return serverif __name__ == "__main__" : async def main () -> None : await (await serve()).wait_closed() asyncio.run(main())
可以看出服务端的代码与多线程的Python gRPC
也类似,但是缺少Context
的参数,如果跳进去helloworld.GretterBase
查看源码:
1 2 3 4 5 6 7 8 9 10 class GreeterBase (ServiceBase ): async def say_hello (self, hello_request: "HelloRequest" ) -> "HelloReply": raise grpclib.GRPCError(grpclib.const.Status.UNIMPLEMENTED) async def __rpc_say_hello ( self, stream: "grpclib.server.Stream[HelloRequest, HelloReply]" ) -> None : request = await stream.recv_message() response = await self.say_hello(request) await stream.send_message(response)
则可以发现在__rpc_say_hellp
方法中的stream
对象是类似于官方Python gRPC
中的Context
,这意味着如果要使用Context
方法时需要自己新增一个方法去处理,好在该库是纯Python
编写的,grpclib
对应客户端的Channel
和服务端对应的Server
代码也封装得很好,不像官方Python gRPC
一样跳进源代码后就不知道它的代码在哪里,所以处理起来比较方便。
不过它也因此拥有了纯Python
编写库的缺点–性能较低, 在经过与gevent
一样的命令进行压测后得出如下数据:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 Summary: Count: 10000 Total: 6.37 s Slowest: 96.38 ms Fastest: 31.54 ms Average: 63.33 ms Requests/sec: 1571.05 Summary: Count: 10000 Total: 5.44 s Slowest: 90.97 ms Fastest: 38.18 ms Average: 53.13 ms Requests/sec: 1839.73
通过数据可以发现,betterproto
的性能基本是多线程模式性能的3成,而这部分性能的差距主要来自于对HTTP2
的解析,如果能把解析HTTP2
的实现进行替换为高性能的版本,那么它的性能就能够接近Python gRPC
官方实现的grpc.aio
了。
此外,grpclib 的功能比官方Python gRPC
比较少,需要开发能力比较强以及对gRPC
了解得比较深的开发者对其进行二次开发,才能使它发挥更好的作用。
3.2.grpc.aio 官方的Python gRPC
现在通过grpc.aio
模块对Asyncio
提供了支持,它的用法与线程版本一致,只是涉及到IO相关的模块是存在于grpc.aio
包中,对于使用者来说,需要把用到的grpc.XXX
模块需要变为grpc.aio.XXX
模块,同时要确保调用代码一处异步,处处异步,比如客户端的代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 import asyncioimport loggingimport grpcfrom grpc_asyncio_example.protos import helloworld_pb2, helloworld_pb2_grpcasync def client (target: str = "localhost:9999" ) -> None : async with grpc.aio.insecure_channel(target) as channel: stub: helloworld_pb2_grpc.GreeterStub = helloworld_pb2_grpc.GreeterStub(channel) response: helloworld_pb2.HelloRequest = await stub.SayHello(helloworld_pb2.HelloRequest(name='you' )) logging.info("Greeter client received: " + response.message)if __name__ == '__main__' : logging.basicConfig() asyncio.run(client())
通过代码可以看出初始化Channel
的代码不是grpc.insecure_channel
而是grpc.aio.insecure_channel
,而且在helloworld_pb2_grpc.GreeterStub
没有发生变化的情况下,调用stub.SayHello
时仍然需要加上await
语法,这是因为Python gRPC
中Stub
的设计模式类似于sans-io ,是否要添加await
语法则是取决于核心IO处理的实现,在这里处理IO的是Channel
,所以开发者在调用stub.SayHello
时最终会由stub
的channel
来发送请求,而channel
发送请求是一个异步IO实现,所以需要添加await
语法。
而对于服务端的改动也是类似的,对应的代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 import asyncioimport loggingimport grpcfrom grpc_asyncio_example.protos import helloworld_pb2, helloworld_pb2_grpcclass Greeter (helloworld_pb2_grpc.GreeterServicer ): async def SayHello ( self, request: helloworld_pb2.HelloRequest, context: grpc.aio.ServicerContext ) -> helloworld_pb2.HelloReply: await asyncio.sleep(request.sleep_millisecond / 1000 ) return helloworld_pb2.HelloReply(message='Hello, %s!' % request.name)async def serve (target: str = "localhost:9999" ) -> grpc.aio.Server: server: grpc.aio.Server = grpc.aio.server() helloworld_pb2_grpc.add_GreeterServicer_to_server(Greeter(), server) server.add_insecure_port(target) logging.info("Starting server on %s" , target) await server.start() return serverif __name__ == '__main__' : logging.basicConfig(level=logging.INFO) async def main () -> None : await (await serve()).wait_for_termination() asyncio.run(main())
可以看到代码中IO相关语法都需要添加await
语句,同时SayHello
方法添加了async
语法,这样就可以在SayHello
函数里面编写await
语句了。 需要注意的是,由于Python gRPC
是通过ServicerContext
来传输数据的,导致ServicerContext
中有些方法是与IO相关的,所以这里用到的ServicerContext
是grpc.aio.ServicerContext
。
对于IO相关的用法可能有一些区别,但是对于其他跟IO无关的功能,则保持跟多线程模式的一致,比如客户端在启用时填写的参数如下:
1 2 3 4 5 with grpc.insecure_channel(target='localhost:50051' , options=[('grpc.lb_policy_name' , 'pick_first' ), ('grpc.enable_retries' , 0 ), ('grpc.keepalive_timeout_ms' , 10000 ) ]) as channel:
而在grpc.aio
中代码如下:
1 2 3 4 5 async with grpc.aio.insecure_channel(target='localhost:50051' , options=[('grpc.lb_policy_name' , 'pick_first' ), ('grpc.enable_retries' , 0 ), ('grpc.keepalive_timeout_ms' , 10000 ) ]) as channel:
可以看到填写的参数Key名和值都是一样的,没有发生变化。
这里只是以一些常见的功能举例,简述不同模式Python gRPC
的使用,详细的grpc.aio
功能见grpc_asyncio文档 ,如果熟悉多线程模式下Python gRPC
和Python Asyncio
的用法,那么上手grpc.aio
是非常容易的。
3.2.1.grpc.aio的事件循环 在Python gRPC
的grpc.aio
中使用到了asyncio
的事件循环,所以可以通过uvloop
来提高grpc.aio
的性能,使用uvloop
非常简单,只要在代码最前面添加如下代码:
1 2 import uvloop uvloop.install()
就可以使程序获得uvloop
带来的性能加成,在对使用asyncio.loop
和uvloop.loop
的服务端代码进行压测后结果如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 Summary: Count: 10000 Total: 2.15 s Slowest: 30.56 ms Fastest: 11.09 ms Average: 21.19 ms Requests/sec: 4643.73 Summary: Count: 10000 Total: 1.45 s Slowest: 25.14 ms Fastest: 9.41 ms Average: 14.08 ms Requests/sec: 6885.81
可以发现grpc.aio
的性能比多线程模式稍好一些,同时在使用了uvloop
后性能提升了50%(以Requests/sec
对比)。
不过在与用uvicorn
运行的服务中使用gRPC
客户端时,需要确保在uvicorn
启动后才初始化grpc.aio.Channel
,因为在初始化grpc.aio.Channel
时会获取到一个事件循环,如果当前没有事件循环时则会自动创建一个事件循环,而uvicorn
在启动是会单独创建一个新的事件循环,这会导致grpc.aio.Channel
与uvicorn
不在同一个事件循环运行导致运行出错。不过这个问题也很好解决,解决代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 from typing import Anyimport grpcfrom starlette.applications import Starlettefrom grpc_asyncio_example.protos import helloworld_pb2, helloworld_pb2_grpcdef create_app () -> Starlette: app: Starlette = Starlette() async def _before_server_start (*_: Any ) -> None : app.state.channel = grpc.aio.insecure_channel("localhost:9999" ) async def _after_server_stop (*_: Any ) -> None : await app.state.channel.close() app.add_event_handler("startup" , _before_server_start) app.add_event_handler("shutdown" , _after_server_stop) return appif __name__ == "__main__" : import uvicorn starlette_app: Starlette = create_app() uvicorn.run(starlette_app)
在所示的代码中,grpc.aio.channel
在startup
事件时才进行初始化,在shutdown
事件关闭,由于触发startup
事件时,uvicorn
已经初始化了一个事件循环,所以初始化grpc.aio.Channel
时获取到的事件循环与uvicorn
一致。
4.总结 如果为了能跟上社区的脚步,以及为了跨语言能够很好的合作,建议还是根据服务所在场景来使用多线程默认的gRPC
或者grpc.aio
,这是最好的选择; 对于类似gunicorn
+ gevnet
+ 类似于Flask
框架的服务通过gRPC
客户端发送请求时可以使用gevent
兼容模式; 对于开发能力比较强,本身也是追求Pythonic
的开发者且是基于Asyncio
生态编写服务以及有比较强的定制需求的,则可以考虑使用betterproto
。