---
title: "Bidirectional streaming"
description: "grpc bidi streaming в Go: поток в обе стороны, BidiStreamingServer, горутина чтения и цикл Send, CloseSend, чат, дедлайны и согласование горутин."
course: "gRPC-Go: высокопроизводительный RPC"
url: "https://golangmaster.ru/courses/grpc-go/lessons/09-bidi/"
published: "2026-09-19"
updated: "2026-09-26"
minutes: 24
---

# Bidirectional streaming

*Курс «gRPC-Go: высокопроизводительный RPC», урок 9 из 25. ~24 минут. Обновлено 26.09.2026. Источник: https://golangmaster.ru/courses/grpc-go/lessons/09-bidi/*

grpc bidi streaming — четвёртая и последняя модель обмена gRPC, финальная строка таблицы типов из шестого урока: слово stream стоит и слева, и справа от returns, и обе стороны говорят одновременно. В той таблице она ждала под гипотетическим именем ChatTickets(stream TicketMsg) returns (stream TicketMsg) — сегодня гипотеза станет сервисом: добавим ChatService с чатом тикетов поддержки и напишем обе стороны по-настоящему. Двунаправленный стрим — это два независимых потока сообщений внутри одного вызова: клиент шлёт, не дожидаясь ответов, сервер отвечает, не дожидаясь конца входа, и жёсткого порядка «запрос — ответ» больше не существует. Плата за свободу — две горутины и дисциплина полузакрытий: каждый конец закрывает свою сторону сам, а ошибки из Send и Recv читаются иначе, чем в восьмом уроке. Разберём дженерики grpc.BidiStreamingServer и grpc.BidiStreamingClient, каноническую горутинную схему с каналом waitc, согласование с контекстом и его Done(), дедлайны и честный ответ на вопрос, когда дуплекс нужен, а когда это стрельба из пушки по воробьям.

Три урока подряд мы строили обмен по очереди: unary — спросил и ждал, server streaming — спросил и слушал поток, client streaming — молча шлёт батч и получает одну сводку. Все три — полудуплекс: в каждый момент говорит одна сторона. Но половина реальных систем так не живёт: чат поддержки, где клиент и оператор печатают одновременно; многопользовательская игра, где состояние мира течёт в обе стороны; синхронизация двух реплик, каждая из которых и отправляет изменения, и принимает чужие. Для них в .proto зарезервирована последняя комбинация: stream в обе стороны. В таблице шестого урока эта строка стояла с пометкой «нет в схеме» и примером ChatTickets(stream TicketMsg) returns (stream TicketMsg) — сегодня она перестанет быть гипотетической. Мы расширим приложение магазина отдельным сервисом чата и пройдём по всей механике: почему направления независимы, зачем клиенту две горутины, что значит io.EOF теперь — он появится на обоих концах с разными смыслами, — и как не превратить свободу дуплекса в гонку горутин.

### grpc bidi streaming: обе стороны говорят сразу

Bidirectional streaming — четвёртая модель вызова: клиент отправляет последовательность сообщений и сервер отправляет последовательность, причём каждая сторона шлёт столько, когда и как хочет, не спрашивая разрешения у другой. В .proto это stream слева и справа: rpc ChatTickets (stream TicketMsg) returns (stream TicketMsg) читается как «поток TicketMsg на входе, поток TicketMsg на выходе». Русскоязычный запрос «дуплекс grpc» — ровно про эту конструкцию: полный дуплекс, а не полудуплекс первых трёх моделей. Разницу стоит прочувствовать через технику: рация — полудуплекс, говорить может либо тот, либо другой; телефон — дуплекс, оба говорят одновременно, и никто никого не перебивает. Unary, server streaming и client streaming — рации с разной очередностью; bidi — телефон. Ключевое свойство, из которого растёт всё остальное: направления независимы. Нет жёсткого порядка «запрос — ответ»: сервер может ответить на первое сообщение, не дожидаясь остальных; может сам начать говорить до любого входа — например, послать приветствие сразу после открытия вызова; может закончить свою сторону, не дочитав клиентскую. Сообщений может быть сколько угодно в каждую сторону, и числа не обязаны совпадать: тридцать входящих и пять исходящих — легально. Один ctx накрывает весь вызов целиком, оба направления делят его судьбу: отмените контекст — умрут оба потока сразу, отдельной изоляции направлений нет. И заметьте: wire-механика та же, что во всех предыдущих уроках — HTTP/2, заголовки, grpc-timeout, статус в trailers, — просто сообщений теперь много в обе стороны.

### Контракт ChatTickets: proto и сгенерированные подписи

```go
// api/shop/v1/chat.proto — чат поддержки рядом с сервисом заказов.
service ChatService {
  // stream СЛЕВА И СПРАВА от returns — двунаправленный стрим:
  // на входе поток TicketMsg, на выходе тоже поток TicketMsg.
  rpc ChatTickets (stream TicketMsg) returns (stream TicketMsg);
}

message TicketMsg {
  int64 ticket_id = 1; // тикет поддержки (обсуждаем, например, заказ 42)
  string author = 2;   // кто пишет: "client" или "operator"
  string text = 3;     // текст сообщения
}

// Сгенерированный chat_grpc.pb.go — обе стороны одного метода.

// Клиент: контекст — единственный аргумент, поток в обе стороны.
ChatTickets(ctx context.Context, opts ...grpc.CallOption) (
    grpc.BidiStreamingClient[TicketMsg, TicketMsg], error)

// Сервер: ни req, ни ctx — стрим забрал всё, как в client streaming.
ChatTickets(grpc.BidiStreamingServer[TicketMsg, TicketMsg]) error

// Дескриптор метода: оба флага true — впервые в курсе.
{StreamName: "ChatTickets", ServerStreams: true, ClientStreams: true}
```

Дженерик стал двойным в обе стороны: BidiStreamingClient[TicketMsg, TicketMsg] параметризован и входом, и выходом, потому что теперь есть и то, и другое, и то, и другое — потоки. Клиентская подпись похожа на client streaming из восьмого урока лишь наполовину: параметра-запроса нет, но и CloseAndRecv исчез — вместо одной склейки клиент получит два отдельных метода. Серверная — точная копия формы восьмого урока: ни req, ни ctx. Генератор оставляет псевдонимы ChatService_ChatTicketsClient = grpc.BidiStreamingClient[TicketMsg, TicketMsg] и ChatService_ChatTicketsServer = grpc.BidiStreamingServer[TicketMsg, TicketMsg] для обратной совместимости, а StreamDesc впервые показывает пару ServerStreams: true и ClientStreams: true — в unary было false и false, в седьмом уроке true и false, в восьмом false и true. Канонический пример — RouteChat(stream RouteNote) returns (stream RouteNote) из route_guide: тот же официальный образец, что давал нам WatchOrders-подобный ListFeatures и RecordRoute, закрывает и третью модель.

### Серверная сторона: BidiStreamingServer без SendAndClose

Серверный интерфейс grpc.BidiStreamingServer[TicketMsg, TicketMsg] из stream_interfaces.go — с grpc-go 1.64 дженериковый, под старым именем ChatService_ChatTicketsServer прячется псевдоним — несёт два метода плюс встроенный ServerStream с привычными Context, SetHeader, SendHeader и SetTrailer. Recv() (*TicketMsg, error) блокируется до очередного входящего; документация описывает исходы дословно так: «If io.EOF is returned, it indicates the client called CloseSend on its BidiStreamingClient. Any other error indicates the stream was terminated unexpectedly, and the handler method should return, as the stream is no longer usable». Сравните с восьмым уроком: там io.EOF значил, что клиент вызвал CloseAndRecv, — закрыл свою сторону и ждёт единственный ответ. Здесь клиент вызывает CloseSend — закрывает свою сторону и продолжает спокойно читать: он не просит один ответ, он просто закончил говорить. Send(*TicketMsg) error звучит как в server streaming: «The server handler may call Send multiple times to send multiple messages to the client», а ошибка Send означает, что стрим мёртв и хендлеру пора возвращаться. И главное отличие от восьмого урока — тип-док интерфейса: «To terminate the stream, return from the handler method and return an error from the status package, or use nil to indicate an OK status code». Никакого SendAndClose в bidi не существует вообще: выйти из хендлера — и значит закрыть свою сторону; return nil пишет статус OK, status.Error — свой код. Метод «отправить последнее и закрыть» упразднён, потому что «последнего» сообщения у сервера больше нет: любое может оказаться не последним.

### Хендлер ChatTickets: читаем и отвечаем по ходу

```go
// internal/server/chat.go — операторский бот: отвечает на каждое сообщение.
func (s *ChatServiceServer) ChatTickets(
        stream pb.ChatService_ChatTicketsServer) error {
        for {
                in, err := stream.Recv()
                switch {
                case err == io.EOF:
                        // Клиент вызвал CloseSend: его сторона закрыта.
                        // Отвечать срочно не нужно — возвращаемся со статусом OK.
                        return nil
                case err != nil:
                        // Стрим сломан (клиент ушёл, дедлайн, сеть):
                        // статус уже записан библиотекой, возвращаем как есть.
                        return err
                }
                // Отвечаем на каждое сообщение сразу, не дожидаясь
                // конца входного потока: направления независимы.
                reply := &pb.TicketMsg{
                        TicketId: in.GetTicketId(),
                        Author:   "operator",
                        Text:     "принято: " + in.GetText(),
                }
                if err := stream.Send(reply); err != nil {
                        return err
                }
        }
}
```

Родственник хендлера RouteChat из официального route_guide: там сервер копит заметки по координатам и на каждое входящее отвечает всеми накопленными в этой точке — эхо-переписка на стероидах; мы ограничились простым эхом, каркас тот же. Бесконечный for, внутри Recv, io.EOF первым делом — он здесь не ошибка, а вежливое «я закончил говорить» от клиента. Обратите внимание на два состояния после io.EOF: клиентская сторона закрыта, но вызов жив — сервер ещё может отправлять, пока не вернётся из хендлера; return nil — это и есть закрытие серверной половины. Геттеры GetTicketId и GetText — nil-безопасные помощники protobuf из урока про синтаксис: клиент мог прислать пустое сообщение, хендлер обязан пережить это без паники. И заметьте: сервер отвечает строго на вход — но мог бы шлить и сам, по таймеру или по событию в базе; направление ответов ему никто не ограничивает.

### Клиентская сторона: Send, Recv и CloseSend по отдельности

Клиентский интерфейс grpc.BidiStreamingClient[TicketMsg, TicketMsg] — два метода плюс встроенный ClientStream. Документация перечисляет, что даёт встраивание: «to provide Context, Header, Trailer, and CloseSend functionality» — и вот здесь новая география: CloseSend, который в восьмом уроке был спрятан внутри CloseAndRecv и не вызывался вами вручную, теперь часть публичного контракта. Склейки CloseAndRecv в bidi нет — некого «дожидаться одного ответа», — поэтому закрытие своей стороны вы делаете явным вызовом stream.CloseSend(). Send(*TicketMsg) error описана теми же словами, что в восьмом уроке, с одним важным сдвигом: «On error, Send aborts the stream. If the error was generated by the client, the status is returned directly. Otherwise, io.EOF is returned, and the status of the stream may be discovered using Recv()». Восьмой урок заканчивал эту фразу CloseAndRecv(), здесь — Recv(): правду об отказе сервера теперь узнаёт цикл чтения. Recv() (*TicketMsg, error) описана как в server streaming: «If io.EOF is returned, the stream has terminated with an OK status. Any other error is compatible with the status package and indicates the RPC's status code and message» — io.EOF на клиенте означает, что вызов завершён со статусом OK, прочие ошибки статусные. Забавная деталь под капотом: GenericClientStream реализует сразу ServerStreamingClient, ClientStreamingClient и BidiStreamingClient — в stream_interfaces.go стоят соответствующие проверочные присваивания var _ BidiStreamingClient[int, string] = (*GenericClientStream[int, string])(nil), и то же верно для GenericServerStream. То есть клиентский объект физически один и тот же во всех трёх стриминговых моделях — разница лишь в том, какие методы вы зовёте и сколько горутин вы завели.

### Сквозной клиент: горутина чтения и основной цикл Send

```go
// cmd/client/main.go — клиент чата: пишет и читает одновременно.
func main() {
        // Канал без сетевых операций — как в клиентском уроке.
        conn, err := grpc.NewClient("localhost:50051",
                grpc.WithTransportCredentials(insecure.NewCredentials()))
        if err != nil {
                log.Fatalf("канал: %v", err)
        }
        defer conn.Close()
        client := pb.NewChatServiceClient(conn)

        // Дедлайн накрывает весь вызов — оба направления сразу.
        ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
        defer cancel()

        stream, err := client.ChatTickets(ctx)
        if err != nil {
                log.Fatalf("ChatTickets: %v", err)
        }

        // Горутина чтения: единственный Recv-цикл на весь вызов.
        waitc := make(chan struct{})
        go func() {
                defer close(waitc)
                for {
                        in, err := stream.Recv()
                        if err == io.EOF {
                                return // сервер закрыл поток со статусом OK
                        }
                        if err != nil {
                                log.Printf("чтение: %v", err)
                                return
                        }
                        fmt.Printf("[%s] %s\n", in.GetAuthor(), in.GetText())
                }
        }()

        // Основная горутина — единственный отправитель: цикл Send,
        // затем половинное закрытие своей стороны.
        for _, msg := range readUserInput() {
                if err := stream.Send(msg); err != nil {
                        break // правду узнает горутина чтения из Recv
                }
        }
        stream.CloseSend() // всегда возвращает nil, статус ищут в Recv
        <-waitc            // дождаться хвоста ответов после CloseSend
}
```

Это каноническая схема из runRouteChat официального примера route_guide: канал waitc, отдельная горутина с Recv-циклом, io.EOF из Recv — закрыть waitc, основная горутина шлёт Send-циклом, затем CloseSend, и в конце main дожидается <-waitc. Зачем дожидаться: ответы могут догонять уже после CloseSend — вы закрыли свою половину, сервер дочитал и досылает хвост; выход без <-waitc теряет эти сообщения. Две мелочи, на которых спотыкаются: CloseSend по документации всегда возвращает nil — повторный и бесполезный вызовы не отличить от полезного, статус вызова ищут в Recv; а в горутине чтения стоит log.Printf вместо log.Fatalf, потому что Fatalf зовёт os.Exit и убивает процесс, не дав отработать даже defer close(waitc).

### Горутинная схема: два цикла и один писатель

Почему клиент обязан завести горутину? Потому что один поток событий читать и писать по очереди нельзя — вы либо застрянете в Recv, не послав следующее, либо в Send, не прочитав пришедшее. Документация SendMsg в stream.go отвечает на вопрос конкуренции дословно: «It is safe to have a goroutine calling SendMsg and another goroutine calling RecvMsg on the same stream at the same time, but it is not safe to call SendMsg on the same stream in different goroutines. It is also not safe to call CloseSend concurrently with SendMsg». Разложим по полочкам. Первое: горутина, зовущая SendMsg, и горутина, зовущая RecvMsg, могут работать одновременно — это прямо разрешено и на клиенте, и на сервере, у библиотеки нет состояния, которое ломалось бы от такого параллелизма. Второе: два писателя запрещены — Send из разных горутин на одном стриме небезопасен, очередь отправки одна. Третье: CloseSend нельзя звать одновременно с SendMsg — он тоже участник отправляющей половины. Этим разрешаются оба тизера прошлых уроков: запрет конкурентного Send из server и client streaming остаётся в полной силе — писатель всегда один, — но вот чтение параллельно с записью впервые становится легальным и обязательным. Отсюда канонический шаблон: «горутина чтения» крутит Recv-цикл, основная горутина ведёт Send-цикл, роли можно поменять местами, но отправитель всегда один. Про двух читателей документация молчит: Recv прямо разрешено звать многократно, но это тоже приёмник с состоянием, и на практике приём всегда закрепляют за одной горутиной — так же, как отправку. Под капотом всё по-прежнему: Send блокируется до появления flow control — места в HTTP/2-окне, — «ушло в транспорт» не значит «сервер прочитал», а медленный читатель на другой стороне затормозит вашего отправителя через окно, без всяких таймеров.

### Эхо-сервер и три сообщения. Сколько строк напечатает клиент?

```go
// Сервер: на каждое входящее отвечает эхом, после io.EOF — return nil.
// (хендлер ChatTickets из этого урока)

// Клиент: горутина чтения печатает каждое полученное сообщение
// через fmt.Println, затем main выполняет:
for _, t := range []string{"раз", "два", "три"} {
        stream.Send(&pb.TicketMsg{Text: t})
}
stream.CloseSend()
<-waitc
// сколько строк напечатает горутина чтения и чем завершится вызов?
```

**Что выведет программа?**

- Одну — эхо первого сообщения: остальные сервер не прочитал
- ✅ Три — по эху на каждое сообщение, вызов завершится без ошибок
- Четыре — три эха и напечатанный io.EOF из финального Recv
- Программа зависнет: <-waitc никогда не сработает

Внутри одного направления сообщения строго упорядочены — протокол гарантирует порядок доставки в потоке. Сервер получил «раз» — отправил эхо, получил «два» — отправил эхо, получил «три» — отправил эхо; клиентская горутина чтения напечатает ровно три строки в порядке отправки. Затем CloseSend ставит финальный фрейм, серверный Recv возвращает io.EOF, хендлер возвращает nil — статус OK уезжает в trailers, и клиентский Recv тоже даёт io.EOF: горутина закрывает waitc, <-waitc отпускает, программа завершается чисто. Четвёртый вариант исключён ровно потому, что io.EOF из Recv ничего не печатает — это сигнал конца, а не сообщение; зависания нет, потому что и серверное закрытие, и статус OK доезжают даже после half-close клиента.

### Хореография завершения: два полузакрытия вместо одного

В bidi каждый конец закрывает свою половину сам, и эти два события независимы. Клиент закрывает отправку вызовом CloseSend — по-английски half-close, половинное закрытие: «я закончил говорить, но продолжаю слушать». Сервер закрывает свою сторону возвратом из хендлера: return nil — статус OK, status.Error — соответствующий код. Нормальная хореография выглядит так: клиент шлёт сообщения, потом CloseSend; серверный Recv даёт io.EOF — клиентская сторона закрыта, но вызов ещё жив; сервер досылает хвост ответов и возвращает nil; статус OK пишется в trailers; клиентский Recv, дочитав очередь, возвращает io.EOF — теперь вызов завершён, и только после этого по документации можно читать Trailer(): «It must only be called after stream.CloseAndRecv has returned, or stream.Recv has returned a non-nil error (including io.EOF)». Аварийные хореографии: сервер отказал — status.Error из хендлера, клиентский Recv вернёт статусную ошибку, а его Send с этого момента — io.EOF; клиент уронил вызов отменой контекста — у сервера очередной Recv вернёт Canceled или DeadlineExceeded, у клиента Send — Canceled, знакомые по восьмому уроку. Самое коварное место новички путают io.EOF: в bidi он встречается на ОБОИХ концах с разными смыслами. На сервере io.EOF из Recv — «клиент закрыл свою половину» — это середина жизни вызова, отвечать ещё можно. На клиенте io.EOF из Recv — «вызов завершён со статусом OK» — это финал. Два io.EOF, две роли, перепутаете — получите хендлер, который уходит вместе с первым же half-close клиента, потеряв половину диалога.

### Контекст, Done() и дедлайны в живом чате

Один ctx накрывает весь вызов, и grpc-timeout едет в заголовках, как в unary: сервер видит дедлайн в stream.Context(), и это тот же контекст, что был в седьмом и восьмом уроках. Но в чате контекстная механика важнее, чем где-либо, потому что оба цикла могут застрять: Recv ждёт сообщения, которое может не прийти никогда, Send упирается в flow control, когда читатель на той стороне исчез, а оператор может молчать хоть час. Первое правило выживания: у каждого блокирующего места должен быть путь выхода. Для Send и Recv путь даёт сам контекст — их разбужет отмена или дедлайн со статусной ошибкой. Для собственного кода между ними — select по stream.Context().Done(): хендлер, который между сообщениями ждёт канал с данными из базы, обязан одновременно слушать и Done, иначе уйдёт клиента и зависнет навсегда. Второе правило: чат обязан уметь кончаться. Бесконечная сессия держит хендлер и не даст GracefulStop из серверного урока завершиться — то же предохранительное правило, что для вечной подписки WatchOrders. Дедлайн выбирается по сценарию сессии: пять минут на разговор, час на смену оператора; для «пока пользователь не положит трубку» берут context.WithCancel и отменяют по решению пользователя. Третье правило — согласование горутин: канал waitc из клиентского шаблона — это ваш локальный Done()-канал, сигнал «горутина чтения закончила»; main дожидается его перед выходом, а при сложном финале — в select вместе с ctx.Done(). Так два асинхронных цикла и общий контекст собираются в один аккуратный жизненный цикл без утечек горутин.

### Сессия, которая умеет кончаться тремя способами

```go
// cmd/client/main.go — жизненный цикл чата: ввод, отмена, дедлайн.
func main() {
        // ... NewClient + client := pb.NewChatServiceClient(conn) ...

        // Дедлайн сессии: пять минут на весь разговор.
        ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
        defer cancel()

        stream, err := client.ChatTickets(ctx)
        if err != nil {
                log.Fatalf("ChatTickets: %v", err)
        }

        // Горутина чтения: единственный Recv-цикл, печатает ответы.
        waitc := make(chan struct{})
        go func() {
                defer close(waitc)
                for {
                        in, err := stream.Recv()
                        switch {
                        case err == io.EOF:
                                fmt.Println("оператор закрыл чат")
                                return
                        case err != nil:
                                fmt.Printf("чтение: %v\n", err)
                                return
                        }
                        fmt.Printf("оператор: %s\n", in.GetText())
                }
        }()

        // Ввод пользователя едет в канал: main остаётся единственным
        // отправителем — двух писателей на стриме быть не должно.
        input := make(chan string)
        go func() {
                sc := bufio.NewScanner(os.Stdin)
                for sc.Scan() {
                        input <- sc.Text()
                }
                close(input) // Ctrl+D: пользователь закончил говорить
        }()

        for {
                select {
                case line, ok := <-input:
                        if !ok {
                                // Пользователь закончил: половинное закрытие,
                                // хвост ответов дочитывает горутина чтения.
                                stream.CloseSend()
                                <-waitc
                                return
                        }
                        if err := stream.Send(&pb.TicketMsg{Text: line}); err != nil {
                                // io.EOF — стрим закрыт не нами; правду узнает Recv.
                                <-waitc
                                return
                        }
                case <-ctx.Done():
                        // Дедлайн сессии: горутина чтения получит ошибку
                        // из Recv и закроет waitc — дожидаемся и выходим.
                        <-waitc
                        return
                }
        }
}
```

Три способа кончиться — три ветки. Ctrl+D закрывает канал input: main зовёт CloseSend, дожидается waitc и уходит по-хорошему — сервер увидит io.EOF, отработает и напишет OK. Ошибка из Send — стрим уже мёртв: правду напечатает горутина чтения из своего Recv, main лишь дожидается её. Дедлайн — ctx.Done(): cancel отработает по defer, Recv вернёт DeadlineExceeded, waitc закроется, программа завершится без утечек. Заметьте дисциплину: main — единственный отправитель, две вспомогательные горутины только читают stdin и ничего не знают о стриме; все пути выхода сходятся к <-waitc, и ни одна горутина не остаётся висеть.

### Ошибки из Send и Recv: связка с прошлыми уроками

Все знакомые ошибки возвращаются, но права на чтение статуса перераспределились, потому что CloseAndRecv исчез. Начнём с Send. Клиентская ошибка — сообщение больше maxSendMessageSize с кодом ResourceExhausted и текстом «trying to send message larger than max» — возвращается из Send напрямую: её «сгенерировал клиент». Ошибка транспортная — сервер отказал, сеть оборвалась — сворачивается в io.EOF: csAttempt.sendMsg в stream.go работает здесь ровно как в client streaming, потому что SendMsg один и тот же. Но в восьмом уроке за правдой шли в CloseAndRecv — теперь идти некуда, и документация переставила акцент: «the status of the stream may be discovered using Recv()». Клиент ещё шлёт, сервер уже ответил отказом — Send даёт io.EOF, а горутина чтения из своего Recv достаёт настоящий статус. Правильная реакция клиента на io.EOF из Send — не паниковать и не ретраить, а выйти из цикла отправки и довериться горутине чтения. Теперь Recv. Отмена клиента или дедлайн — Canceled или DeadlineExceeded; сеть — Unavailable; обрыв посреди сообщения — io.ErrUnexpectedEOF, который библиотека превращает в codes.Internal. На сервере всё зеркально: клиент ушёл — Send хендлера вернёт Canceled, как в седьмом уроке, а отложенный WriteStatus при ошибке Recv сам доставит причину клиенту. Дедлайн — DeadlineExceeded у обеих сторон. Единственный смысл, которого у io.EOF в этом уроке два: на сервере — half-close клиента (середина жизни вызова), на клиенте — статус OK (финал). Всё это — та же карта ошибок восьмого урока, переразложенная по двум горутинам: держите в голове, какая горутина узнаёт какую правду, и чат перестанет пугать.

### Проверка: io.EOF на сервере

**Вопрос:** Хендлер bidi-метода вызвал stream.Recv() и получил io.EOF. Что это значит?

- Вызов завершён: клиент уже получил итоговый статус
- ✅ Клиент вызвал CloseSend: его сторона закрыта, но он продолжает читать ваши ответы
- Сеть оборвалась: стрим непригоден, нужно вернуть статусную ошибку
- Клиент нарушил протокол: это кардинальное нарушение, будет codes.Internal

Дословно из документации BidiStreamingServer.Recv: «If io.EOF is returned, it indicates the client called CloseSend on its BidiStreamingClient» — клиент половинно закрыл свою сторону и остался слушать. Это середина жизни вызова, а не финал: после io.EOF хендлер ещё может отправлять сообщения, и они дойдут; закрыть свою половину он должен возвратом из хендлера. Разница с восьмым уроком принципиальна: там io.EOF значил CloseAndRecv — клиент ждал ровно один ответ; здесь CloseSend — клиент ничего не ждёт, он просто закончил говорить. Сетевой отказ даёт не io.EOF, а статусную ошибку, и кардинальности в bidi не существует вовсе.

### Проверка: конкуренция на стриме

**Вопрос:** Что документация gRPC говорит о конкуренции горутин на одном стриме?

- Send и Recv можно звать из любого числа горутин — библиотека сама ставит в очередь
- ✅ Одна горутина может звать SendMsg, другая — RecvMsg одновременно, но Send из разных горутин и CloseSend одновременно с Send небезопасны
- Весь стрим, и отправка, и приём, должен обслуживаться ровно одной горутиной
- Конкурентный Send безопасен, если чередовать вызовы через мьютекс стрима

Цитата из stream.go: «It is safe to have a goroutine calling SendMsg and another goroutine calling RecvMsg on the same stream at the same time, but it is not safe to call SendMsg on the same stream in different goroutines. It is also not safe to call CloseSend concurrently with SendMsg». Это и есть фундамент bidi-шаблона: читать и писать параллельно можно и нужно, писатель всегда один, а CloseSend — участник отправляющей половины, ему тоже нужна синхронизация с Send. Мьютекс, кстати, вариант легальный — но идиоматичнее раздавать право голоса архитектурно: одна горутина-отправитель, все остальные просят через канал.

### CloseSend дважды и Send вдогонку. Что напечатает код?

```go
stream, _ := client.ChatTickets(ctx)
defer stream.CloseSend() // страховка на выходе

// ... шлём сообщения, затем закрываем свою сторону сами:
stream.CloseSend() // половинное закрытие после последнего сообщения

err := stream.Send(&pb.TicketMsg{Text: "послание вдогонку"})
fmt.Println(err)
```

**Что выведет программа?**

- nil — повторный CloseSend ничего не меняет, сообщение уйдёт
- io.EOF — стрим уже закрыт, транспорт возвращает конец данных
- ✅ codes.Internal с текстом «SendMsg called after CloseSend»
- codes.Canceled — вызов завершился вместе с половинным закрытием

Две механики в одном вопросе. Повторный CloseSend безвреден: документация ClientStream говорит «This method always returns a nil error», а реализация в stream.go прямо отвечает nil на повтор — «Return a nil error on repeated calls to this method» — поэтому паттерн с defer stream.CloseSend() как страховкой легален и полезен. Но sentLast уже взведён, и первый после этого Send упирается в проверку clientStream.SendMsg: codes.Internal с дословным текстом «SendMsg called after CloseSend» — знакомым по восьмому уроку. io.EOF из Send — признак транспортной проблемы, а клиентское нарушение всегда возвращается статусом напрямую: библиотека отлично знает, кто закрыл стрим.

### Кардинальность N на M: проверок больше нет

В восьмом уроке мы держали в голове железную пару: запросов сколько угодно, ответ ровно один, и за каждым следила библиотека — Internal про cardinality violation, если ответ не пришёл. В bidi обе этих проверки мертвы: StreamDesc с ServerStreams: true и ClientStreams: true выключает их обе, потому что каждая стреляла только при false-флаге. Кардинальность становится N на M: тридцать входящих и пять исходящих, пять входящих и тридцать исходящих, ноль и ноль — всё легально, и библиотека не скажет ни слова. Сервер может ответить до первого входящего сообщения — открыть вызов приветствием и вести диалог, не дожидаясь, пока клиент соберётся. Клиент может закрыть свою половину, не получив ни одного ответа, — и это не ошибка, а дизайн. SendAndClose не существует, «обязательного последнего сообщения» нет, и это одновременно освобождение и ответственность: протокол больше не навязывает диалоговую дисциплину, её задаёт ваше приложение. Какие сообщения на какие отвечать, как связать ответ с вопросом, если они приходят вперемешку, — всё это ваша схема сообщений: в чате тикетов роль диспетчера играет поле ticket_id, и без такой маркировки дуплекс мгновенно превращается в кашу. Здесь же корень честного сравнения с WebSocket: bidi — это не «просто труба», а типизированный RPC с двумя упорядоченными очередями, статусом в конце, метаданными и кодогенерацией; но порядок обработки, маршрутизация и смысл сообщений лежат на приложении ровно так же, как там. Отсюда и цена отладки: в unary трасса «запрос — ответ» тривиальна, а в дуплексе переплетение двух очередей двух сторон — и чем богаче протокол, тем нужнее идентификаторы в каждом сообщении.

> **Один писатель, два полузакрытия.** Дисциплина bidi в четырёх строчках. Первая: отправитель один — Send из двух горутин на любом конце запрещён, чтение параллельно с записью разрешено и обязательно. Вторая: CloseSend не зовут одновременно с Send и не путают с завершением вызова — после него Send даст codes.Internal «SendMsg called after CloseSend», а Recv продолжит работать. Третья: сервер завершает свою половину только возвратом из хендлера — nil это OK, status.Error это код; SendAndClose в bidi нет. Четвёртая: io.EOF — два разных сигнала: на сервере «клиент закончил говорить», на клиенте «вызов завершён со статусом OK»; перепутать — потерять половину диалога.

### Когда bidi уместен, а когда это стрельба из пушки

Четвёртая модель — самая богатая и самая дорогая, и критерий выбора жёстче, чем где-либо: берите bidi, когда направления действительно независимы и оба живут долго. Классические сюжеты: чат и поддержка — обе стороны пишут по мере мыслей; многопользовательские игры — состояние мира течёт туда и обратно непрерывно; синхронизация и репликация — две стороны обмениваются изменениями, каждая и шлёт, и принимает; живая транскрипция — аудио течёт от клиента, расшифровка капает обратно; совместное редактирование — операции коллег прилетают, пока ты печатаешь. Общий знаменатель: ни одна сторона не может спланировать очередь заранее, и паузы в одном направлении не должны блокировать другое. Когда bidi не нужен. Первый красный флаг — скрытый запрос-ответ: «шлём сообщение и ждём ответа на него» — это четыре unary-вызова или пары из седьмого и восьмого уроков, только дороже в поддержке. Второй — полудуплекс хватает: если клиент всегда сначала говорит, потом слушает, вы платите за две горутины и синхронизацию, не используя дуплекс. Третий — разовая передача: выгрузка батча — территория client streaming. Есть и объективная цена: два цикла вместо одного, каналы согласования, отладка переплетённых очередей, а один ctx на оба направления означает отсутствие изоляции — отказ серверного направления убивает и клиентскую отправку, отдельной «полуотмены» не бывает. Метрики и трассировка сложнее: вызов живёт долго, и «время ответа» приходится изобретать на уровне сообщений. Если сомневаетесь — начните с полудуплексных моделей и объединяйте в bidi только тогда, когда почувствовали реальную боль от очереди ходов.

### Четыре модели в одной таблице

| Вопрос | Unary (урок 06) | Server streaming (07) | Client streaming (08) | Bidi (этот урок) |
| --- | --- | --- | --- | --- |
| stream в .proto | нигде | справа от returns | слева от returns | слева и справа |
| Схема обмена | 1 запрос, 1 ответ | 1 запрос, поток ответов | поток запросов, 1 ответ | поток, поток |
| Хендлер сервера | GetFeature(ctx, req) | ListFeatures(req, stream) | RecordRoute(stream) | ChatTickets(stream) |
| Клиентское закрытие | сам вызов кончается | отмена ctx | CloseAndRecv (= CloseSend + RecvMsg) | CloseSend руками + Recv-хвост |
| io.EOF у серверного Recv | — | — | клиент вызвал CloseAndRecv | клиент вызвал CloseSend |
| io.EOF у клиентского Recv | — | конец потока при статусе OK | — | конец потока при статусе OK |
| Кардинальность | ровно 1 / 1 | 1 / N | N / ровно 1 | N / M — проверок нет |
| SendAndClose | — | — | обязателен, ровно один раз | не существует |

### Соберите каркас bidi-клиента

Восстановите канонический каркас: открытие стрима, горутина чтения с waitc, цикл Send и половинное закрытие.

```go
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()

stream, err := client.___(ctx) // открыть двунаправленный стрим
if err != nil {
        log.Fatalf("ChatTickets: %v", err)
}

// Горутина чтения: единственный цикл приёма.
waitc := make(chan struct{})
___ func() {
        defer close(waitc)
        for {
                in, err := stream.___()
                if err == ___.EOF {
                        return // сервер закрыл поток со статусом OK
                }
                if err != nil {
                        log.Printf("чтение: %v", err)
                        return
                }
                fmt.Println(in.GetText())
        }
}()

for _, msg := range outbound {
        stream.Send(msg) // один отправитель на весь вызов
}
stream.___() // половинное закрытие своей стороны
<-waitc      // дождаться хвоста ответов
```

Подсказка: Метод клиента — ChatTickets; горутину запускает go; принимает Recv; конец потока — io.EOF; закрывает свою сторону CloseSend.

Правильные ответы в пропусках: `ChatTickets`, `go`, `Recv`, `io`, `CloseSend`

### Жизненный цикл bidi-вызова

Расставьте события чата ChatTickets — от открытия стрима до финального io.EOF у клиента.

Правильный порядок строк:

1. `Клиент: client.ChatTickets(ctx) — grpc-timeout уезжает в заголовках вызова`
2. `Клиент: стартует горутина чтения — её Recv-цикл единственный на приём`
3. `Клиент: stream.Send(msg) — основная горутина ведёт свою сторону`
4. `Сервер: stream.Recv() возвращает сообщение — ответ уходит через stream.Send, не дожидаясь конца потока`
5. `Клиент: сообщения кончились — stream.CloseSend() закрывает половину клиента`
6. `Сервер: stream.Recv() возвращает io.EOF — клиентская сторона закрыта, вызов ещё жив`
7. `Сервер: хендлер досылает хвост ответов и возвращает nil — статус OK пишется в trailers`
8. `Клиент: stream.Recv() возвращает io.EOF — горутина чтения закрывает waitc, вызов завершён`

### Чек-лист bidirectional streaming

- Схема: stream слева и справа — rpc ChatTickets (stream TicketMsg) returns (stream TicketMsg); StreamDesc держит оба флага true.
- Сервер: grpc.BidiStreamingServer — Recv в цикле, Send сколько нужно, SendAndClose не существует; свою половину закрывает возврат из хендлера: nil — OK, status.Error — код.
- io.EOF из серверного Recv — клиент вызвал CloseSend: это середина жизни вызова, отвечать ещё можно.
- Клиент: grpc.BidiStreamingClient — Send + Recv + CloseSend; CloseAndRecv нет, половинное закрытие делается руками.
- Горутинная схема: горутина чтения с Recv-циклом и канал waitc, основной цикл Send; отправитель всегда один — два Send на стриме запрещены, CloseSend не конкурентен с Send.
- io.EOF из Send — транспортная проблема, правду достаёт горутина чтения из Recv; клиентская ошибка приезжает из Send статусом напрямую.
- Кардинальность N на M без проверок: ноль сообщений в обе стороны легальны, ответы возможны до первого входа; маршрутизацию сообщений задаёт ваша схема (ticket_id).
- Дедлайн или WithCancel — обязательно: чат обязана уметь кончаться, каждый блокирующий вызов должен иметь путь выхода через ctx.Done() или waitc.

### Что дальше по курсу

Отдельная честная оговорка про формат: в курсе есть песочница с интерпретатором Go, но сеть в браузере недоступна — поднять gRPC-сервер и поговорить с ним оттуда невозможно, поэтому runner-ов в этом уроке, как и во всём gRPC-курсе, нет. Всё разобранное проверяется локально за минуты: сервер из урока 04, клиентский каркас из пятого урока — и ChatTickets оживёт: пошлите три сообщения и посмотрите, в каком порядке приходят эхи, вызовите CloseSend после первого и убедитесь, что хендлер продолжает отвечать, а затем добавьте в хендлер второй Send-цикл из новой горутины и словите гонку, которую запретил этот урок. Механику дуплекса полезно сверить с соседями: [серверный стрим седьмого урока](/courses/grpc-go/lessons/07-server-streaming/) — половина этой модели, а [client streaming восьмого урока](/courses/grpc-go/lessons/08-client-streaming/) — вторая её половина; bidi просто убирает очередь ходов между ними. Дальше по маршруту: metadata и токены в уроке про метаданные — в живом чате именно там лежит авторизация, ведь заголовки уезжают при открытии стрима; глубокие дедлайны и распространение отмены — в уроке про дедлайны. А базовая wire-механика unary шестого урока — grpc-timeout, trailers, статусные ошибки — остаётся фундаментом всех четырёх моделей. Полный маршрут курса — на [странице gRPC-Go](/courses/grpc-go/).

### Карточки: bidirectional streaming под микроскопом

- **Bidirectional streaming** — Четвёртая модель: stream слева и справа — rpc ChatTickets (stream TicketMsg) returns (stream TicketMsg). Два независимых потока в одном вызове, кардинальность N на M без проверок, порядок «запрос — ответ» не навязан. Полный дуплекс против полудуплекса первых трёх моделей.
- **grpc.BidiStreamingServer[Req, Res]** — Серверный интерфейс (дженерик с grpc-go 1.64): Recv() (*Req, error) + Send(*Res) error + встроенный ServerStream. io.EOF из Recv — клиент вызвал CloseSend. SendAndClose нет: свою половину закрывает возврат из хендлера — nil это OK, status.Error это код.
- **grpc.BidiStreamingClient[Req, Res]** — Клиентский интерфейс: Send(*Req) error + Recv() (*Res, error) + ClientStream с публичным CloseSend. io.EOF из Recv — вызов завершён со статусом OK. Транспортная ошибка Send — io.EOF, правду достаёт Recv; клиентская — статусом напрямую.
- **CloseSend** — Половинное закрытие: «закончил говорить, продолжаю слушать». Всегда возвращает nil, повтор безвреден (защита sentLast), но не конкурентен с SendMsg. Send после него — codes.Internal «SendMsg called after CloseSend». Recv после него продолжает работать.
- **Горутинная схема bidi** — Горутина чтения крутит единственный Recv-цикл и закрывает канал waitc на io.EOF; основная горутина ведёт Send-цикл, затем CloseSend, затем <-waitc. Канон — runRouteChat из route_guide. Отправитель всегда один: Send из разных горутин запрещён.
- **io.EOF в bidi: два смысла** — На сервере из Recv — клиент вызвал CloseSend, вызов жив, отвечать можно. На клиенте из Recv — поток завершён со статусом OK, это финал. Отмена и дедлайн дают не io.EOF, а статусные Canceled и DeadlineExceeded у обеих сторон.

## Коротко: что запомнить

- Bidirectional streaming — stream слева и справа от returns: rpc ChatTickets (stream TicketMsg) returns (stream TicketMsg); направления независимы, порядок «запрос — ответ» не навязан, кардинальность N на M.
- Сервер: grpc.BidiStreamingServer — Recv в цикле и Send сколько нужно; SendAndClose не существует, свою половину закрывает возврат из хендлера: nil — статус OK, status.Error — код.
- Клиент: grpc.BidiStreamingClient — Send, Recv и публичный CloseSend; CloseAndRecv нет, половинное закрытие делается руками, хвост ответов дочитывается после него.
- Горутинная схема: горутина чтения с единственным Recv-циклом и каналом waitc, основной цикл Send; отправитель один — два Send на стриме запрещены, CloseSend не конкурентен с Send.
- io.EOF теперь двух цветов: у серверного Recv — клиент вызвал CloseSend (середина жизни вызова), у клиентского Recv — вызов завершён со статусом OK (финал).
- io.EOF из Send — транспортная проблема: правду достаёт горутина чтения из Recv; клиентские ошибки приезжают из Send статусом напрямую, как в восьмом уроке.
- Один ctx на оба направления: дедлайн или WithCancel обязательны — чат обязан уметь кончаться, у каждого блокирующего вызова есть путь выхода через ctx.Done() или waitc.

## Домашнее задание

Соберите ChatService по раскладке урока и прогоните три сценария. Первый: эхо-хендлер из урока плюс клиент с горутиной чтения — отправьте три сообщения, закройте половину через CloseSend и убедитесь, что все три эха дошли после него, а <-waitc отпустило main. Второй: научите сервер отвечать до первого входа — приветствие сразу после открытия вызова; на клиенте проверьте, что горутина чтения печатает его, пока main ещё ничего не отправил. Третий: откажите в хендлере status.Error(codes.PermissionDenied) на первом же сообщении и проследите пару: Send у клиента вернёт io.EOF, а горутина чтения получит PermissionDenied из Recv — это та же связка, что в восьмом уроке, но правду теперь достаёт Recv. Затем поставьте сессии таймаут в две секунды и добавьте хендлеру паузу: обе стороны обязаны увидеть DeadlineExceeded, а main — выйти через waitc без утечки горутин. Наконец, добавьте в клиент второй Send-цикл из отдельной горутины и убедитесь, что это та самая запрещённая гонка: писатель на стриме всегда один.

## Вопросы и ответы по уроку

### Чем bidi лучше нескольких параллельных unary-вызовов?

Один вызов — один HTTP/2-поток: заголовки и метаданные уезжают один раз, сообщения в каждом направлении упорядочены, один ctx и один статус на всю сессию. Параллельные unary дают независимые вызовы, но размножают обвязку и не дают упорядоченности внутри диалога. Для долгого двустороннего разговора bidi дешевле и проще; для независимых вопросов — unary честнее.

### Кто должен закрывать стрим первым?

Никто «первый» не назначен: каждый конец закрывает свою половину сам — клиент вызовом CloseSend, сервер возвратом из хендлера. Эти события независимы и в нормальном диалоге клиент обычно закрывает раньше. Договоритесь о протоколе: кто и когда уходит, что значит io.EOF от каждой стороны, — иначе получите зависшие хендлеры или потерянные хвосты.

### Обязателен ли CloseSend, если программа просто завершается?

Формально нет: ctx отменится, сервер увидит статусную ошибку. Но это грубое завершение: хвост ответов потеряется, сервер отличит «закончил говорить» от «умер» только по статусу. CloseSend — вежливое половинное закрытие: сервер получает io.EOF, досылает остаток и пишет чистый OK. Дешёвый вызов, заметная разница.
