Disclaimer: this is not another one gRPC hate article... Oh, whait...

Начнем издалека - знаете, всегда было интересно, а почему, собственно, для golang существует такое большое разнообразие библиотек, для каких-то часто используемых сущностей, как-то - роутеры http (fasthttprouter забыли, как подсказали в коментах) или cache?

С выбором RPC вроде все просто, gRPC - наше всё (вы, кстати, в курсе, что g здесь - это не Google внезапно). Но не тут-то было...

Все просто без ума от Мэри gRPC (нет).

Начнем с того, что в golang изначально реализовали net/rpc со своим сериализатором gob. Типа есть потребность - в golang есть решение из коробки (так же история, что и с роутером http - он есть, но все используют сторонние решения из-за параметризованных путей запросов). И тут засада - этот rpc можно только между golang приложениями использовать. Потом выкатили gRPC и все заверте... Вкратце - gRPC использует http/2 и protobuf для сериализации (запомним, rpc - это протокол обмена плюс сериализатор). Причем gRPC реализация доступна для многих языков, фактически нет привязки, на чем писать клиентскую и серверную часть. So far, so good...

Однако не все так гладко... Понятно стремление Google объять все возможные кейсы, но! К оригинальной реализации gRPC со временем появилось куча вопросов. Иначе как объяснить, что куча контор начали пилить свои собственные реализации RPC (и/или сериализаторов)? Также, внезапно, выяснилось, что требования к RPC внутри облака (читай между микросервисами) и RPC между клиентами за пределами облака/датацентра и сервисами внутри него (за ingress/proxy/load balancer - как хотите называйте) как бы "немножко" разные? Да и выбор http/2 в качестве транспорта - ну кто-же знал, что внедрёж пойдет не так (быстро), как ожидалось.

Начнем с сериализаторов, общепризнанный фаворит - gogo/protobuf (форк golang/protobuf), генерирует более быстрый код сериализации за счет переиспользования памяти и отказа о рефлексии/указателей, а так же других оптимизаций, но постойте - он же Deprecated (и теперь ищут new ownership)? А это потому, что после перехода Google на protobuf API v2, разработчики gogo предпочли забить на проект (это прискорбно), чем переписать его код почти целиком. Хотя вот пример, как с gogo на API v2 переходили - Things Learned From Trying to Migrate To Protobuf V2 API from gogoprotobuf (So Far).

Но есть еще энтузиасты - зацените vtprotobuf. Парни из Vitess заморочились, и таки написали свой сериализатор под protobuf API v2, причины и цифры смотрим в статье A new Protocol Buffers generator for Go.

Кстати - не protobuf единым, как говорится, например та же Google когда-то замутила flatbuffers сериализатор. Интересно то, что gRPC вообще-то поддерживает кастомные сериализаторы, а не только protobuf из коробки. Вот пример проекта Dgraph (которые начинали как раз с net/rpc с flatbuffers вместо gob), а потом перешли на gRPC, но тоже с flatbuffers - Custom encoding: Go implementation in net/rpc vs grpc and why we switched.

Вообще, как упоминалось ранее - есть 100500 разных реализаций отдельных сущностей (наверное, это все-таки не проблема конкретно golang), вот github репа, где сравнивается производительность всех (наверное) существующих сериализаторов для golang, правда результаты там довольно странные по состоянию на сейчас (gob медленнее JSON - это как вообще?), если сравнивать по годам:

2022/09/05 - Go 1.16.5 linux/amd64 i7-3630QM

benchmark

iter

time/iter

bytes/op

allocs/op

Json_Marshal-8

189709

6090

151

208

Json_Unmarshal-8

92833

12751

151

383

Gob_Marshal-8

71692

16463

163

1616

Gob_Unmarshal-8

14772

84385

163

7688

Goprotobuf_Marshal-8

1405010

854

53

64

Goprotobuf_Unmarshal-8

973688

1255

53

168

Gogoprotobuf_Marshal-8

3359550

354

53

64

Gogoprotobuf_Unmarshal-8

1908633

619

53

96

Musgo_Marshal-8

4294477

280

46

48

Musgo_Unmarshal-8

2498404

480

46

96

2021/06/21 - Go 1.16.5 linux/amd64 i7-3630QM

benchmark

iter

time/iter

bytes/op

allocs/op

Json_Marshal-8

501478

2538

151

208

Json_Unmarshal-8

226456

5023

151

383

Gob_Marshal-8

1320562

882

63

40

Gob_Unmarshal-8

1000000

1041

63

112

Goprotobuf_Marshal-8

3247056

378

53

64

Goprotobuf_Unmarshal-8

1839267

651

53

168

Gogoprotobuf_Marshal-8

5886194

204

53

64

Gogoprotobuf_Unmarshal-8

3464098

345

53

96

Musgo_Marshal-8

12882543

86

0

0

Musgo_Unmarshal-8

3381966

343

96

96

В другом месте нашлись более "релевантные" результаты:

2022/03/19 Go 1.17.8 Darwin/arm64 Apple M1 Max

benchmark

iter

time/iter

bytes/op

allocs/op

Json_Marshal-8

1440837

822

148

208

Json_Unmarshal-8

653754

1817

148

399

Gob_Marshal-8

2750721

440

63

40

Gob_Unmarshal-8

2918254

410

63

112

Goprotobuf_Marshal-8

6831308

176

53

64

Goprotobuf_Unmarshal-8

5746256

210

53

168

Gogoprotobuf_Marshal-8

16528346

72

53

64

Gogoprotobuf_Unmarshal-8

12764978

94

53

96

Musgo_Marshal-8

22535546

53

48

0

Musgo_Unmarshal-8

12952696

90

48

96

В общем, gogo быстрее в два раза реализации от Google. Кстати, можно заметить в таблице некий musgo - очень даже неплохо себя показывает (ибо codegen). Вероятно, в таблицу стоило вставить достаточно известный msgpack - проект от opensource сообщества, который все никак не взлетит как следует (но подвижки вроде есть). Для дополнительного чтения - Зоопарк в Golang MSA. Protobuf, MessagePack, Gob – что выбрать?

Идем дальше. Все чаще разрабы задаются вопросом, а чой-та golang gRPC такой монструозный в плане оверхеда на зависимости? И почему под капотом у него собственная реализация http/2 стека, а не переиспользование пакета "golang.org/x/net/http2" (ну да, типы и конфиги из него используются, но не более). И вообще - не так все гладко с пробросом http/2 через load balancers.

Дабы решить две упомянутые проблемы - зависимости от кода (читай, постоянной войны с багами и breaking changes, которые в Google, видимо - "нормальное" явление) и поддержки http 1.1, в Twitch запилили свой фреймворк Twirp (кстати, http/2 тоже поддерживается из стандартной библиотеки golang) - Twirp: a sweet new RPC framework for Go, о нем и на Habr тоже писалось - Twirp против gRPC. Стоит ли?

По тем же причинам в Storj тоже разработали свою альтернативу gRPC - DRPC, см. статью Introducing DRPC: Our Replacement for gRPC, причем они рассматривали Twirp, как возможное решение, но в нем не оказалось нужной фичи - стриминга (как в gRPC), которую в DRPC тоже реализовали.

Постойте-ка, до сих пор все разговоры велись о RPC между, условно говоря, облаком и клиентами на PC/Mobile. А зачем такие навороты для взаимодействия микросервисов? Почему не plain TCP (или даже UDP, в сетевых игрушках так делают иногда)? Ах, да - net/rpc же есть (что вам еще нужно-то, как бы спрашивает Google).

Нужно больше производительности и фич! Так появилась сначала библиотека valyala/gorpc, а затем и valyala/fastrpc от Александра Валялкина, автора fasthttp (читать про неё тут на Habr - Грехи оптимизации производительности).

При ближайшем рассмотрении оказывается, что на самом деле RPC реализаций много (например rpcx, kitex, arpc, сравнение их производительности с gRPC и net/rpc - 2022 Go Ecosystem rpc Framework Benchmark), но на слуху у всех gRPC как некая "серебряная пуля".

И про UDP based RPC - есть проект Hprose (High Performance Remote Object Service Engine) от китайских товарищей, он поддерживается для многих языков, и для golang тоже есть реализация, так вот - там есть поддержка UDP. Кроме того, вышеупомянутый rpcx поддерживает TCP, HTTP, QUIC (который под капотом UDP) and KCP (так сказать китайский вариант QUIC, тоже на UDP).

Ну и напоследок, к вопросу как работает gRPC под капотом... Оказывается, есть простой способ его ускорить. Тут вот какие-то слоупоки пишут в 2022 году-то The Mysterious Gotcha of gRPC Stream Performance, у нас такой трюк в PROD уже года 4 используется: как известно, в gRPC есть простые вызовы и стриминговые, так вот - если сделать пул стримов вместо простого вызова, то все работает быстрее приблизительно в два раза (с последовательными или конкурентными запросами - неважно), абстрактный пример:

api.proto

syntax = "proto3";

package pb

message Request {}
message Response {}

service Service {
  rpc Unary (Request) returns (Response);
  rpc Stream (stream Request) returns (stream Response);
}

server.go

func (s *grpcServer) Unary(ctx context.Context, req *pb.Request) (*pb.Response, error) {
	return &pb.ResponseDomain{}, nil
}

func (s *grpcServer) Stream(stream pb.Service_StreamServer) error {
	ctx := stream.Context()
	for {
		select {
		case <-ctx.Done():
			return ctx.Err()
		default:
		}

		req, err := stream.Recv()
		if err == io.EOF {
			break
		}

		if err != nil {
			return err
		}

		resp, _ := s.Unary(ctx, req)
		if err := stream.Send(resp); err != nil {
			return err
		}
	}
	return nil
}

client.go

func (c *grpcClient) Call(ctx context.Context, req *pb.Request) (*pb.Response, error) {
	if !c.streams {
		return c.client.Unary(ctx, req)
	}

	stream := c.getStreamFromPool()
	if stream == nil {
		return nil, fmt.Errorf("no stream")
	}

	if err := stream.Send(req); err != nil {
		stream, err = c.client.Stream(ctx)
		if err != nil {
			return nil, err
		}
	}

	defer c.putStreamToPool(stream)
	return stream.Recv()
}

Но никто не знает, когда это cломается (хотя это хак, как ни крути), иначе таких твитов бы не было, я думаю.

Комментарии (0)