golang

Пишем свой TCP-чат на Go: пошаговый гайд на пальцах

  • четверг, 5 марта 2026 г. в 00:00:09
https://habr.com/ru/articles/1006316/

Привет, Хабр! Начинаю серию статей, которые позволят погрузиться начинающим разработчикам в сетевое программирование на Go.

В этой статье мы напишем простой консольный чат, используя только стандартную библиотеку. Никаких фреймворков и лишних зависимостей — только чистый код и понимание того, как данные передаются по сети. Понимание сокетов — это фундамент для написания высоконагруженных сервисов, микросервисов и понимания того, как работает интернет «под капотом».

Порог для понимания статьи: синтаксис Go.

Цель — создание простого tcp сервера для обмена сообщениями.

Задачи на сегодня:

  1. Поднимем TCP-сервер, слушающий порт.

  2. Реализуем подключение клиентов.

  3. Настроим рассылку сообщений всем подключенным пользователям.

Шаг 1: создание проекта

На данном этапе я хочу показать, что любая задача решаемая, если разбить ее на подзадачи и постепенно решать каждую, поэтому ставлю себе цель выдать простой гайд, чтобы появилась мотивация идти дальше, не боясь неизвестни. Поэтому все сделаем максимально просто, ограничемся подключением клиента, чтением сообщений и рассылкой их всем клиентам сервера.

Создадим самую простую структуру:

├── chat
│   ├── cmd
│   │   └── main.go     #точка входа 
│   └── server
│       └── server.go   #код нашего сервера

Шаг 2: начинаем реализацию нашего сервера

a) В server.go создаём структуру сервера:

package server

import (
	"net"
)

type Server struct {
	Address  string
	Listener net.Listener
}


func NewServer(address string) *Server {
	return &Server{
		Address: address,
	}
}

Address — строка хост + порт, через которые можно будет подключиться к нашему серверу.

Listener — интерфейс сетевого прослушивателя, который будет отвечать за прием входящих подключений и полученией соединений для обмена потоками байтов.

Функция NewServer — конструктор для удобного создания экземпляра сервера.

b) Далее нам нужно реализовать метод запуска нашего сервера:

func (s *Server) Start() error {
	var err error
	s.Listener, err = net.Listen("tcp", s.Address)
	if err != nil {
		log.Printf("Error accept: %v", err)
		return err
	}

    log.Printf("Server started")
    go s.acceptLoop()
    select{}
	return nil
}

В данном методе мы запускаем сервер на указанных хосте и порте в атрибуте Address и инициализируем Listener. select блокирует выполнение метода, чтобы он не завершался (позже реализуем нормальную остановку сервера). В случае неудачи пишем в лог и возвращаем ошибку. дальше мы вызываем метод с циклом подключения соединений:

func (s *Server) acceptLoop() {
	for {
		conn, err := s.Listener.Accept()
		if err != nil {
			log.Printf("Failed accept client: %v", err)
		}
		log.Printf("Welcome, %s", conn.RemoteAddr().String())

	}
}

Метод Accept блокирует цикл до момента получения нового запроса на подключение. в случае успеха мы приветствуем пользователя, выводя его хост + порт в консоль. Пока что это просто заглушка с логгом.

c) Теперь протестируем, что сервер нормально принимает подключения. В файле main.go создаем сервер через конструктор и запустим:

package main

import "tcp-server/chat/server"

func main() {
	server := server.NewServer(":3000")
	server.Start()
}

В окне терминала через telnet подключимся к нашему серверу:

telnet localhost 3000

Консоль сервера:

└─$ go run chat/cmd/main.go
2026/02/23 20:01:54 Server started
2026/02/23 20:01:59 Welcome, 127.0.0.1:47294

Успех, оно работает!

Шаг 3: рассылка сообщений

Наш сервер стал подавать признаки жизни, а значит пора начать принимать сообщения клиента и рассылать всем подключенным. Для этого нам нужно реализовать метод, который в бесконечном цикле будет читать поток байтов из подключения:

func (s *Server) handleConn(conn net.Conn) {
	defer conn.Close()

	buf := make([]byte, 2048)
	for {
		n, err := conn.Read(buf)
		if err != nil {
			log.Printf("Connection error: %v", err)
			return
		}

		msg := &Message{
			Author: conn.RemoteAddr().String(),
			Text:  string(buf[:n]),
		}
		s.messagesChan <- *msg
	}
}

defer conn.Close() — закрываем соединение при завершении работы метода. Даже если клиент отключился, на стороне сервера соединение продолжает считаться открытым. Каждое открытое TCP-соединение занимает память в ядре операционной системы.

Для получения сообщения нам нужно иниализировать буффер (buf) со стандартной емкостью для чата 2Кб. Конечно можно было бы использовать возможности стандартной библиотеки, но она добавляет лишнее копирование. Если клиент передаст сообщение длиной больше 2048 байт, оно не будет прочитано сразу полностью, оставшиеся байты будут ждать обработки в буффере ос.

Далее в бесконечном цикле мы блокируем выполнение и ждем данных от сети. В качестве аргумента передается наш срез (так как срез ссылается на массив, то данные успешно будут записаны). В случае ошибки мы будем разрывать соединение.

Добавляем обработку соединения в acceptLoop:

func (s *Server) acceptLoop() {
	for {
		conn, err := s.Listener.Accept()
		if err != nil {
			log.Printf("Failed accept client: %v", err)
		}
		log.Printf("Welcome, %s", conn.RemoteAddr().String())
		go s.handleConn(conn) //добавляем горутину
	}
}

Для удобства обмена сообщениями создаем структуру Message и канал, в который будут отправлять все полученные сообщения:

type Message struct {
	Author string
	Text  string
}

type Server struct {
	messagesChan chan Message  //дополним структуру нашего сервера
}

func NewServer(address string) *Server {
	return &Server{
		messagesChan: make(chan Message, 100), //не забываем инициализировать 
                                               //канал при создании
	}
}

После получения сообщения мы преобразуем его в строку, считывая из буфера все n байт, которые передал клиент. В качестве никнейма автора будет использовать удаленный адрес клиента.

Теперь реализуем сущность клиента, чтобы сохранять информацию о подключенных пользователях. На данном этапе отдельная структура избыточна, но в дальнейшем нам она понадобится для хранения метаданных:

type Peer struct {
	Conn        net.Conn
	ConnectedAt time.Time
}

type Server struct {
	clients      map[net.Conn]*Peer //мапа соединений
	mu           sync.RWMutex       //мьютекс для безопасной работы

}

func NewServer(address string) *Server {
	return &Server{
		clients:      make(map[net.Conn]*Peer),
	}
}

Реализуем регистрацию клиентов в системе и добавим метод в начало handleConn:

func (s *Server) registerPeer(conn net.Conn) {
	peer := &Peer{
		Conn:        conn,
		ConnectedAt: time.Now(),
	}

	s.mu.Lock()
	defer s.mu.Unlock()
	s.clients[conn] = peer
}

Теперь нам нужно реализовать два метода, которые будут читать из канала сообщений и делать рассылку всем соединениям:

func (s *Server) Broadcast() {
	for {
		msg := <-s.messagesChan
		s.mu.RLock()
		message := fmt.Sprintf("%s: %s\n", msg.Author, msg.Text)
		for _, client := range s.clients {
			s.writeInConnection(client.Conn, message)
		}
		s.mu.RUnlock()

	}
}

func (s *Server) writeInConnection(conn net.Conn, message string) {
	_, err := conn.Write([]byte(message))
	if err != nil {
		log.Printf("Failed write message: %v", err)
	}
}

Запустим telnet и проверим, что сообщение приходит:

─$ telnet localhost 3000
Trying ::1...
Connected to localhost.
Escape character is '^]'.
Hello world!
[::1]:45224: Hello world!

Шаг 4: Остановка сервера и закрытие соединений

Представьте: вы запустили сервер, подключили несколько клиентов, всё работает... А теперь нужно выключить сервер. Что будет с подключенными пользователями?

Если просто «убить» процесс, клиенты останутся в неведении, а операционная система будет хранить закрытые соединения ещё несколько минут. Это не только неэтично по отношению к пользователю, но и технически опасно для стабильности сервиса.

Давайте научимся останавливать сервер красиво:

  • Корректно закроем каждое соединение.

  • Гарантируем, что все горутины завершат работу без гонок и утечек.

Это тот случай, когда «мелочи» отличают учебный проект от продакшн-готового решения.

В первую очередь нужно обратить внимание на то, что если соединение разорвано и отправка сообщения клиенту вызывает ошибку, нам нужно корректно обработать разрыв подключения, для этого будем собирать всех "мертвых" клиентов, а после рассылки закрывать соединение.

Добавим серверу атрибут deadClients []net.Conn. Обновим наши методы для корректного разрыва соединения:


func (s *Server) Broadcast() {
	for {
		msg := <-s.messagesChan
		s.mu.RLock()
		message := fmt.Sprintf("%s: %s\n", msg.Author, msg.Text)
		for _, client := range s.clients {
			s.writeInConnection(client.Conn, message)
		}
		s.mu.RUnlock()

		for _, conn := range s.deadClients { 
			s.unregisterPeer(conn)      //вызываем закрытие соединения
		}
		s.deadClients = s.deadClients[:0]
	}
}

func (s *Server) writeInConnection(conn net.Conn, message string) {
	_, err := conn.Write([]byte(message))
	if err != nil {
		log.Printf("Failed write message: %v", err)
		s.deadClients = append(s.deadClients, conn) //при ошибке добавляем в срез
	}
}

func (s *Server) unregisterPeer(conn net.Conn) {
	s.mu.Lock()
	defer s.mu.Unlock()
	conn.Close()
	delete(s.clients, conn)
	log.Printf("Client disconnected: %s", conn.RemoteAddr().String())
}

Теперь нам нужно реализовать функционал для правильной остановки сервера. Необходимо создать контекст, который будет закрыт при остановке сервера, что вызовет закрытие всех соединений и остановку горутин.

В методе Start инициализируем контекст, который будет отслеживать два события: syscall.SIGINT — завершение по Ctrl + C в терминале; syscall.SIGTERM — завершение по kill <pid>. В методах acceptLoop и Broadcast будем блокировать return ctx.Done(). Обновим код:

func (s *Server) Start() error {
	//остальной код без изменений
	ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
	go s.acceptLoop(ctx)
	go s.Broadcast(ctx)
	defer func() {
		cancel()
		close(s.messagesChan)
	}()
	return nil
}

func (s *Server) acceptLoop(ctx context.Context) {
	for {
		conn, err := s.Listener.Accept()
        if err != nil {
            select {
            case <-ctx.Done():
                log.Println("Accept loop stopped")
                return
            default:
                log.Printf("Accept error: %v", err)
                continue
            }
        } //остальной код без изменений
	}
}

func (s *Server) Broadcast(ctx context.Context) {
	for {
		select {
		case <-ctx.Done():
			return
		case msg := <-s.messagesChan:
			//код рассылки сообщений
		}
	}
}

И на финальном этапе нам осталось корректно завершить работу сервера, создадим метод Stop, который будет блокировать выполнение метода Start до получения сигнала от контекста:

func (s *Server) Stop(ctx context.Context) {
	<-ctx.Done()
	for _, client := range s.clients {
		s.unregisterPeer(client.Conn)
	}
}

Обновляем метод старт, добавляя в defer закрытие Listener. После закрытия будет прочитан канал ctx и корректно завершена работа acceptLoop:

func (s *Server) Start() error {
	//код без изменений
	ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
	go s.acceptLoop(ctx)
	go s.Broadcast(ctx)
	defer func() {
		cancel()
		s.Listener.Close()
		close(s.messagesChan)
	}()
	s.Stop(ctx)
	return nil
}

Наш TCP-чат готов!

проверяем работу, клиент 1:

hello
[::1]:44454: hello

Клиент 2:

[::1]:44454: hello

hi, bro!
[::1]:53866: hi, bro!

Клиент 1:

hello
[::1]:44454: hello

[::1]:53866: hi, bro!

Заключение

Поздравляю! Вы только что написали свой первый TCP-сервер на Go с нуля. Не просто «Hello, World», а полноценный чат с:

  • Многопоточной обработкой клиентов,

  • Потокобезопасной рассылкой сообщений,

  • Graceful shutdown и корректным управлением ресурсами.

Это тот фундамент, на котором строятся высоконагруженные мессенджеры, игровые серверы, брокеры сообщений и микросервисы.

В следующих статьях будем копать глубже в сетевое программирование.

В качестве задач для самостоятельной работы реализуйте:

  • Рассылку о подключении нового клиента и выхода клиента всем участникам чата;

  • Рассылку о завершении работы сервера;

  • Ввод никнейма пользователя, который будет использоваться вместо удаленного адерса.

Готовый код
package server

import (
	"context"
	"fmt"
	"log"
	"net"
	"os/signal"
	"sync"
	"syscall"
	"time"
)

type Peer struct {
	Conn        net.Conn
	ConnectedAt time.Time
}

type Message struct {
	Author string
	Text   string
}

type Server struct {
	Address      string
	Listener     net.Listener
	clients      map[net.Conn]*Peer
	deadClients  []net.Conn
	mu           sync.RWMutex
	messagesChan chan Message
}

func NewServer(address string) *Server {
	return &Server{
		Address:      address,
		messagesChan: make(chan Message, 100),
		clients:      make(map[net.Conn]*Peer),
		deadClients:  make([]net.Conn, 0),
	}
}

func (s *Server) Start() error {
	var err error
	s.Listener, err = net.Listen("tcp", s.Address)
	if err != nil {
		log.Printf("Error accept: %v", err)
		return err
	}

	log.Printf("Server started")

	ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
	go s.acceptLoop(ctx)
	go s.Broadcast(ctx)
	defer func() {
		cancel()
		s.Listener.Close()
		close(s.messagesChan)
	}()
	s.Stop(ctx)
	return nil
}

func (s *Server) acceptLoop(ctx context.Context) {
	for {

		conn, err := s.Listener.Accept()
		if err != nil {
			select {
			case <-ctx.Done():
				log.Println("Accept loop stopped")
				return
			default:
				log.Printf("Failed accept client: %v", err)
			}
		}
		log.Printf("Welcome, %s", conn.RemoteAddr().String())
		go s.handleConn(conn)
	}
}

func (s *Server) handleConn(conn net.Conn) {
	defer conn.Close()

	s.registerPeer(conn)
	buf := make([]byte, 2048)
	for {
		n, err := conn.Read(buf)
		if err != nil {
			log.Printf("Connection error: %v", err)
			return
		}

		msg := &Message{
			Author: conn.RemoteAddr().String(),
			Text:   string(buf[:n]),
		}
		s.messagesChan <- *msg
	}
}

func (s *Server) Broadcast(ctx context.Context) {
	for {
		select {
		case <-ctx.Done():
			return
		case msg := <-s.messagesChan:
			s.mu.RLock()
			message := fmt.Sprintf("%s: %s\n", msg.Author, msg.Text)
			for _, client := range s.clients {
				s.writeInConnection(client.Conn, message)
			}
			s.mu.RUnlock()
			for _, conn := range s.deadClients {
				s.unregisterPeer(conn)
			}
			s.deadClients = s.deadClients[:0]
		}
	}
}

func (s *Server) writeInConnection(conn net.Conn, message string) {
	_, err := conn.Write([]byte(message))
	if err != nil {
		log.Printf("Failed write message: %v", err)
		s.deadClients = append(s.deadClients, conn)
	}
}

func (s *Server) registerPeer(conn net.Conn) {
	peer := &Peer{
		Conn:        conn,
		ConnectedAt: time.Now(),
	}

	s.mu.Lock()
	defer s.mu.Unlock()
	s.clients[conn] = peer
}

func (s *Server) unregisterPeer(conn net.Conn) {
	s.mu.Lock()
	defer s.mu.Unlock()
	conn.Close()
	delete(s.clients, conn)
	log.Printf("Client disconnected: %s", conn.RemoteAddr().String())
}

func (s *Server) Stop(ctx context.Context) {
	<-ctx.Done()
	for _, client := range s.clients {
		s.unregisterPeer(client.Conn)
	}
}