惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

The Last Watchdog
The Last Watchdog
博客园_首页
Martin Fowler
Martin Fowler
S
SegmentFault 最新的问题
美团技术团队
小众软件
小众软件
V
V2EX
博客园 - Franky
K
KPMG report finds enterprise disconnect between AI and its ROI | CIO
The GitHub Blog
The GitHub Blog
Microsoft Security Blog
Microsoft Security Blog
Attack and Defense Labs
Attack and Defense Labs
S
Security Affairs
Simon Willison's Weblog
Simon Willison's Weblog
I
Intezer
T
The Exploit Database - CXSecurity.com
有赞技术团队
有赞技术团队
S
Schneier on Security
人人都是产品经理
人人都是产品经理
Security Archives - TechRepublic
Security Archives - TechRepublic
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
K
Kaspersky official blog
PCI Perspectives
PCI Perspectives
AI
AI
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
罗磊的独立博客
O
OpenAI News
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
The Register - Security
The Register - Security
V
Vulnerabilities – Threatpost
GbyAI
GbyAI
博客园 - 【当耐特】
C
Cisco Blogs
大猫的无限游戏
大猫的无限游戏
Help Net Security
Help Net Security
Google DeepMind News
Google DeepMind News
S
Securelist
Application and Cybersecurity Blog
Application and Cybersecurity Blog
P
Proofpoint News Feed
博客园 - 三生石上(FineUI控件)
雷峰网
雷峰网
L
LangChain Blog
SecWiki News
SecWiki News
博客园 - 叶小钗
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
V2EX - 技术
V2EX - 技术
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
J
Java Code Geeks
L
LINUX DO - 热门话题
Cisco Talos Blog
Cisco Talos Blog

Все публикации подряд на Хабре

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет Midjourney в 2026? Мой немного грустный разбор этого шикарного инструмента Никто не любит писать тесты, но ИИ может исправить это IPv8 выглядит как мечта. Поэтому почти наверняка не взлетит Производители вернули в продажу материнки с DDR3. Что происходит? Управление агентом с телефона через Telegram теперь в KodaCode От координации к лидерству: как меняется роль руководителя разработки Я сделала родителям бизнес вместо пенсии: зарабатываем 70 тысяч, мама не даёт продать В три раза быстрее приемка товара и оптимизация трудозатрат на 73%: как «РСТ-Инвент» помог Gulliver Group ИИ-шечный мир победил? О влиянии искусственного интеллекта на игропром Кремль снижает давление на Телеграмм пока Европа строит интернет по паспорту Как CEO, CTO и CIO за 8 часов собрали ИИ-директора, который умеет держать позицию под давлением Как (не) потерять домен за выходные Вместо 8 разных VPS: как я организовал практику студентам на одном сервере Почему твой Open Source проект не замечают? R&D: искусство управления неопределенностью в разработке AI-дефляция: вакансий для разработчиков больше, а рост зарплат — худший за 15 лет Мы отдали управление роботами OpenClaw. Что из этого вышло Галактический ID: система идентификации для всех форм разумной жизни Шесть основ бизнес-анализа: начинаем с вопроса «Кто в игре?» Код-ревью, в котором дело не в коде Данные переехали. Команда — нет Системной подход к сдаче OSWE в 2025 Почему комната управления реактором покрашена в цвет морской пены 4 YAML-файла вместо PySpark: как аналитикам строить пайплайны без разработчиков LLM-агент для поиска свободных доменов: автоматизируем подбор Когда, зачем и как правильно начинать новую сессию в Claude Code? Как я заставил нейросеть писать макросы для FreeCAD Анатомия ИИ‑агента для подбора персонала. От тысячи резюме к топ‑10 за минуты Опыт разработчика как экономика внимания Автономность как точка невозврата: кто будет субъектом в цифровом будущем Обучение ИИ в «диких» условиях: как рутинные действия превращаются в датасеты Как измерить LLM для задач кибербеза: обзор открытых бенчмарков Где хранить код? Сравнение GitHub, GitLab и Bitbucket Математика объясняет, почему нормальное распределение встречается повсюду Почему ваш FinOps не работает: 12 тезисов от практиков Как подписать проектную документацию УКЭП с использованием бесплатных лицензий Pilot Адаптивное администрирование Sigla Vision Я грузил уран в бочки, а потом 20 лет строил ИТ в атомной отрасли Чем позвонить с Эвереста? История и обзор спутниковой связи. Часть 2 Как языковая модель помогает контролировать качество инструктажей по охране труда в металлургии Как не передать на desktop свой IP в РКН Анатомия SAP Privileges: как устроено управление правами в macOS MoneyDev: Сказка про три главных слова Обновлённый токенизатор видео K-VAE 2.0 от Сбера Как сделать диспетчеризацию дома на 1284 квартиры почти бесплатно Как мы разогнали железную дорогу Мы дали агентам рутину. Теперь надо решить — что делать с освободившимся временем Токсичный контент, промпт-хакинг и защита ИИ — всё о Guardrails для LLM Умный город начинается с точного взгляда: как «Фалькон Тех» меняет пространство к лучшему Навайбкодил приложение для анализа графов Почему Дюну так интересно читать? Упрощаем работу с рутиной или как стать Гендальфом Белым Деконструкция Go: CPU, RAM и что там происходит. Go Assembler база. Часть 1.1 Какие профессии исчезнут из-за ИИ, а какие появятся? И что с этим делать Как мы построили IT-отдел, где хочется расти: архитектурные встречи, прозрачные метрики и книжные подарки Rufler: Делаем из Claude Code автономный рой через один YAML-конфиг Sing-box и белый список приложений Как построить надёжный обмен сообщениями в микросервисах: лучшие практики для enterprise OpenAI строит MLM-пирамиду, а McKinsey и Accenture помогают ей в этом Дом, который не построил Фишер (Часть 2) «Сверхзвуковой математик» против «Вдумчивого логиста»: битва алгоритмов 3D-упаковки Мультимодальные модели – грубый и дорогой инструмент Разговоры ничего не стоят. Код тоже Проверки физических лиц: с кого начнет ФНС Топ-10 бесплатных нейросетей для создания видео в 2026 году Первые слои кода: как наши решения сегодня определяют архитектуру ИИ на десятилетия Разработка нового статического анализатора: PVS-Studio JavaScript Поиск уязвимостей ПО: базовый минимум или роскошный максимум Почему оценка персонала не работает как инструмент управления Как мы разработали ИИ-ассистента и сократили рутину продуктовой команды на 50% Как я ушел из найма, нажарил косточек и продал на маркетплейсах на 168 млн в год Когда 1С:ERP уже внедрена, а нормального производственного плана всё ещё нет Как я сделал Claude мультимодальным, подключив к нему Qwen Omni Как приглашение на вакансию мечты превращается в атаку Infrastructure as Code: философия и лучшие практики IaC Тестируем Yandex Code Assistant на задаче, в которой нужно хранить секреты nxs-universal-chart v3.0: новое поколение универсального Helm-чарта Callback Injection: Техника, которая отправила Microsoft Defender в глухой нокаут «Все идеи на стол»: митап как способ вывести проект из тупика Сегодня я узнал нечто новое о GPU благодаря багу в своей игре Как заставить LLM ̶ ̶г̶а̶л̶л̶ю̶ ̶ эволюционировать Карта событий как фундамент аналитики: практический кейс для E-commerce Что выбрать для AI: x86, ARM или RISC-V? Дайджест железа за март Роль соматических мутаций в развитии аутоиммунных заболеваний: путь к избирательной терапии Mythos от Anthropic — тревожный сигнал для всех, а не только для банков Guardrails для LLM на Java: как приручить промпт‑инъекции и токсичные ответы Green-VLA: как мы собрали VLA-модель для реального антропоморфного робота и не потеряли обобщение Финансовая гонка вооружений: почему умные люди добровольно в ней участвуют Эра ИИ-агентов наступила: выбираем лучшего цифрового сотрудника # Практический опыт внедрения WinCC Redundancy на производственном предприятии Сделал MVP за 3 дня, а потом неделю прикручивал оплату. Оно того стоило? Физика против Маска: почему Starship V3 может оказаться ещё одной катастрофой Нефть Венесуэлы: крупнейшие запасы в мире, но не крупнейшая нефтяная держава JPA 4. Переосмысление Hibernate Почему зеркальная фотокамера Nikon D5 десятилетней давности идеально подошла для миссии «Артемида-2» Проект «Уровень-Спутник» или как мы сделали платформу для гидрологов «Замедлиться, чтобы ускориться»: почему ИИ повышает цену ошибок в требованиях и архитектуре Как с нуля поднять трафик IT-компании на 1657% при бюджете 55 тыс. и выжить Pixel-perfect Downsampling — идеальная отрисовка 50 миллионов точек без потерь
Realtime-чат без WebSocket: long-polling, гонки переподключения и дубли пушей
Daniil Nuzhdin · 2026-06-23 · via Все публикации подряд на Хабре

Средний

8 мин

2

Бывает так, что realtime на проекте есть, а WebSocket — нет. Сервер отдаёт сообщения через long-polling (он же Comet): клиент шлёт «висящий» HTTP-запрос, сервер держит его открытым, пока не появятся новые сообщения, отвечает — и клиент тут же открывает следующий. С виду всё просто, по сути — бесконечный цикл из одного запроса.

Просто это ровно до первого запуска на реальном устройстве где-нибудь в метро. Дальше начинается то, ради чего я и пишу эту статью: гонки при переподключении, дубли локальных пушей, два потока сообщений в одном ответе и куча мелких состояний, которые надо аккуратно разруливать. Ниже — как с этим жить, на примере iOS-сервиса (назову его LongPollChatService).

Одна оговорка на весь дальнейший код: в сниппетах я опускаю синхронизацию, чтобы не размазывать идею. В реальном сервисе всё изменяемое состояние long-poll цикла — currentRequestUUID, курсоры, счётчики, словарь отложенных задач — живёт на одном serial context (у меня это отдельная очередь; в другом проекте это мог бы быть actor или main thread). Иначе сам механизм защиты от гонок легко сам становится гонкой, что было бы немного обидно)

Сам цикл: хвостовая рекурсия вместо while

WebSocket держит соединение, и события прилетают сами. С long-polling ты сам себе event loop: получил ответ — запросил снова. В коде это не while, а хвостовая рекурсия — метод запроса в случае успеха вызывает сам себя:

private func requestNewMessages(token: ChatToken, requestUUID: String) {
    apiManager.getMessages(token: token) { [weak self] response in
        guard let self else { return }
        guard self.currentRequestUUID == requestUUID else { return }  // про это — ниже

        switch response {
        case .success(let messages):
            self.handle(messages)
            self.requestNewMessages(token: token, requestUUID: requestUUID)  // снова в цикл
        case .failure:
            self.scheduleReconnect(token: token, requestUUID: requestUUID)   // backoff
        }
    }
}

Полная цепочка старта чуть длиннее: сначала берём токен сессии, потом синхронизируем курсоры (про которые мы пока не знаем — об этом дальше), и только потом уходим в этот «висящий» запрос. Но сердце — вот эти две строки: handle + повторный вызов себя.

Главная проблема: гонки при переподключении

Вот где начинается интересное. Long-polling-запрос живёт долго — секунды, иногда десятки секунд. За это время может случиться что угодно: пользователь свернул приложение, сменил аккаунт, потерял сеть. Нам надо перезапустить цикл. Но старый-то запрос уже летит, и его колбэк прилетит — возможно, уже после того, как мы всё перезапустили.

Если ничего не делать, получаем классику: два конкурирующих цикла, дубли сообщений, рассинхрон курсоров. Отменить сетевой запрос физически не всегда успеваешь — cancel() мог уже не догнать ответ.

Решение, которое мне зашло — токен поколения. У сервиса есть currentRequestUUID. Каждый старт цикла генерит новый UUID, и каждый колбэк первым делом сверяет «а я ещё актуален?»:

private var currentRequestUUID = ""

private func startNewCycle() {
    let uuid = UUID().uuidString
    currentRequestUUID = uuid          // объявляем новое «поколение» — ещё до запроса токена
    fetchToken { [weak self] token in
        guard let self, self.currentRequestUUID == uuid else { return }   // токен устарел — выходим
        self.requestNewMessages(token: token, requestUUID: uuid)
    }
}

// и так — в начале каждого колбэка по всей цепочке:
guard currentRequestUUID == requestUUID else { return }   // я из старого поколения → молча умираю

Суть: мы не пытаемся героически отменить всё, что в полёте. Мы просто помечаем актуальное поколение, а всё устаревшее само себя глушит на входе в колбэк. stop() при этом обнуляет UUID полностью — и тогда вообще все летящие колбэки становятся no-op:

func stop() {
    currentRequestUUID = ""             // теперь ни один guard выше не пройдёт
    requestsCount = 0                   // сбрасываем счётчик активных циклов (про него — ниже)
    chatTokenRequest?.cancelRequest()
    messagesRequest?.cancelRequest()
    deferredTasks.forEach { $0.value.cancel() }
    deferredTasks.removeAll()
    isEnabled = false
}

Поверх этого у меня ещё живёт инвариант «активный цикл ровно один». Счётчик requestsCount при нормальной работе всегда 0 или 1, с ассертом в debug, если вдруг стало больше:

guard requestsCount == 0 else {            // активный цикл уже есть — второй не плодим
    assertionFailure("должен быть ровно один активный long-poll")
    return
}
requestsCount += 1
apiManager.getMessages(token: token) { [weak self] response in
    guard let self else { return }
    guard self.currentRequestUUID == requestUUID else { return }  // устаревший колбэк — выходим
    self.requestsCount -= 1                                        // слот освобождаем только за «свой» цикл
    // ...обработка ответа...
}

Тонкость — в порядке проверок: декремент стоит после сверки поколения, а не в defer. Так и должно быть. stop() сам обнуляет счётчик, поэтому устаревший колбэк, прилетевший уже после остановки, обязан выйти молча и к счётчику не прикасаться — иначе увёл бы его в минус и заблокировал следующий старт. Правило простое: requestsCount трогает только актуальный цикл, а stop() всегда возвращает его в ноль.

Честно — вся эта проверка скорее подстраховка от самого себя: UUID-токена в теории достаточно, но реальный код обрастает ветками (смена аккаунта, ретраи, возврат сети), и проще иметь громкую ассерт-проверку, чем потом ловить второй невидимый цикл по логам. Один раз она меня уже спасла.

Backoff с джиттером

Если запрос упал — нельзя ломиться переподключаться сразу же и в цикле, иначе при сетевом сбое все клиенты дружно заддосят сервер ровно в одну секунду. Нужен растущий интервал плюс случайный разброс (джиттер). И вот с джиттером легко промахнуться: если взять растущую задержку и просто прибавить-отнять к ней пару случайных секунд (base ± random), клиенты всё равно сгрудятся вокруг base — в узкой полосе. От той самой «толпы», что ломится переподключаться разом после сетевого сбоя, это спасает слабо.

Поэтому берут full jitter: задержка — это случайная точка по всему интервалу 0...cap, а не «где-то около base». Так попытки размазываются равномерно, и синхронного всплеска на сервере не возникает. Сам интервал при этом растёт экспоненциально и упирается в потолок:

private let baseDelay: TimeInterval = 2    // стартовая задержка
private let maxDelay: TimeInterval = 30    // потолок
private var attemptCount = 0

private func reconnectInterval() -> TimeInterval {
    defer { attemptCount += 1 }
    let capped = min(baseDelay * pow(2, Double(attemptCount)), maxDelay)  // 2, 4, 8, 16 … ≤ 30
    return .random(in: 0...capped)                                        // вся ширина интервала, не «около base»
}

После успешного ответа attemptCount сбрасывается в 0. Честно говоря, в проде у меня поначалу была версия попроще — ступенька с вычитанием случайных секунд; full jitter — это ровно то, к чему стоило прийти сразу.

Жизненный цикл: фон, сеть, и кто кого будит

Long-polling нельзя оставлять висеть бесконечно — в фоне его всё равно прибьёт система, а зря открытый запрос только жрёт ресурс. Поэтому цикл жёстко привязан к состоянию приложения и сети.

Старт идёт, только если есть активная foreground-сцена:

func run() {
    let isActive = UIApplication.shared.connectedScenes
        .contains { $0.activationState == .foregroundActive }
    if isActive { refresh() } else { stop() }
}

А возврат сети будит цикл сам — через подписку на reachability. С одной оговоркой: реагируем только на переход «не было → появилась», иначе на каждый чих коннективити-менеджера будем дёргать перезапуск:

var wasConnected = connectivity.isConnected
connectivity.addObserver(self) { [weak self] status in
    switch status {
    case .reachable where !wasConnected:
        self?.requestNewMessages()       // сеть вернулась — оживаем
        wasConnected = true
    case .unreachable:
        wasConnected = false
    default:
        break
    }
}

Отдельный слой — отложенные задачи (те самые ретраи с backoff). Это словарь [String: DispatchWorkItem]: каждая задача лежит по своему UUID-ключу и сама себя удаляет по завершении. Получается ручной планировщик поверх GCD — не самый элегантный, зато stop() гасит всё одним проходом по словарю (см. выше).

Два потока сообщений в одном ответе

Защитить сам цикл от дублей — это половина дела. Вторая половина в том, что внутри одного ответа может ехать больше одного независимого потока событий, и у каждого нужен свой курсор. У нас таких потоков ровно два: сообщения приходят сразу для двух «личностей» пользователя — основного аккаунта и привязанного (второй профиль, который можно прицепить и отцепить). Ответ — словарь, где ключ — это id пользователя, а значение — его сообщения:

// { "<основной userID>": [...], "<привязанный userID>": [...] }
messages.forEach { key, containers in
    guard let userID = Int(key) else { return }
    switch userID {
    case primaryUserID:
        parse(containers, cursor: &primaryCursor)
    case linkedUserID:
        parse(containers, cursor: &linkedCursor)
    default:
        assertionFailure("прилетел userID, которого мы не ждали")
    }
}

У каждого потока — свой курсор последнего полученного сообщения (primaryCursor / linkedCursor), они независимы. А прямо в этой же ветке обрабатывается привязка/отвязка второго аккаунта: если в ответе с токеном вдруг появился id привязанного профиля, которого раньше не было — значит, аккаунт только что прицепили, дёргаем делегат. Если пропал — значит, отцепили, и надо вычистить из локальной БД все его чаты и обнулить курсор:

if let linkedID = tokenResponse.linkedUserID {
    if linkedUserID != linkedID {           // аккаунт только что привязали
        delegate?.linkedAccountDidChange(userID: linkedID)
    }
    linkedUserID = linkedID
} else if linkedUserID != .invalid {        // аккаунт отвязали
    dbManager.deleteChats(forLinkedAccount: linkedUserID)
    linkedUserID = .invalid
    linkedCursor = nil
}

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

isFirstLoad: не задублировать пуши на старте

Тонкий продуктовый момент, который легко проворонить. Пока приложение лежало в фоне или было выгружено, сообщения копились — и на холодном старте мы вытягиваем весь этот хвост разом. Вопрос: показывать ли локальный пуш на каждое из них?

Нет. Пока приложение было в фоне, система уже показала по ним обычные remote-пуши — и если на старте мы добавим ещё и локальные, пользователь увидит каждое сообщение дважды. А вот сообщения, которые прилетают уже при открытом приложении (когда remote-пуш не показывается), пушить локально как раз надо — иначе их в интерфейсе ничего не подсветит.

Значит, надо отличить «догружаю накопившийся хвост» от «прилетело новое прямо сейчас». Для этого сравниваем максимальный id с сервера с локальным курсором:

// какой самый свежий id знает сервер на момент старта сессии
let serverMax = max(lastMessageInfo.primaryLastID, lastMessageInfo.linkedLastID ?? .invalid)
let localMax  = max(primaryCursor, linkedCursor ?? .invalid)

// сервер ушёл вперёд → это накопившийся в фоне хвост, локальные пуши по нему НЕ шлём
isFirstLoad = serverMax > localMax

Пока isFirstLoad == true, мы догружаем хвост и молчим; как только курсоры догнали серверный максимум, флаг гаснет — и дальше каждое новое сообщение уже идёт с локальным пушем. По сути пара строк, а именно они отвечают за то, что приложение не заваливает пользователя дублями уведомлений на каждый запуск.

Рассылка наблюдателям: чистка и доставка одним проходом

Сервис раздаёт сообщения наблюдателям (экранам). Держатся они слабыми ссылками, так что при каждой рассылке надо попутно выкидывать тех, кто уже умер (observer == nil). И вот тут я сознательно делаю и то, и другое за один проход — прямо внутри предиката removeAll(where:):

observers.removeAll { info in
    guard info.observer.value != nil else { return true }   // мёртвый → выпиливаем
    if let messages = messages(for: info) {
        DispatchQueue.main.async {
            info.handler(messages)        // живому — доставляем, в том же проходе
        }
    }
    return false                          // живой остаётся в списке
}

Логика простая: мы всё равно идём по массиву, чтобы вычистить мёртвые ссылки, — так почему бы заодно не разослать сообщения тем, кто жив? Делать ради этого второй отдельный forEach смысла мало: получится два прохода по тому же массиву там, где хватает одного. Да, формально это side-effect внутри предиката фильтрации, и тащить такой стиль повсюду я бы не стал — но здесь он осознанный: массив наблюдателей маленький, а «почистить мёртвых и доставить живым» — по смыслу одна операция.

Итого

Если оглянуться на весь сервис, видно одну вещь: почти вся его сложность — не про чаты как таковые, а про то, что long-polling по своей природе тащит состояние. Висящий запрос живёт долго и переживает любое изменение вокруг себя — смену экрана, аккаунта, сети. Поэтому самым важным оказался не парсинг сообщений, а аккуратное обращение с этим «долгоживущим» запросом: токен поколения, чтобы устаревшие колбэки гасли сами; инвариант на единственный активный цикл; и stop(), который честно подчищает за собой.

Три приёма, которые я бы забрал в любой следующий проект без сокетов:

  • UUID-токен поколения — самый дешёвый способ обезвредить устаревшие колбэки, не воюя с гонкой отмены сетевого запроса. Ложится на что угодно: long-polling, SSE, да хоть серию обычных запросов.

  • сравнение курсоров для пушей — буквально пара строк, а избавляет от дублей нотификаций на холодном старте.

  • джиттер на backoff — становится обязательным ровно в тот момент, когда клиентов больше одного.

Со временем это, наверное, переехало бы на Swift Concurrency со структурированной отменой — тогда половина ручного жонглирования UUID’ами и счётчиками ушла бы внутрь Task и его cancellation, а serial context стал бы actor’ом. Но даже сейчас, без корутин, главная мысль не меняется: в realtime без сокетов отмену и переподключение надо закладывать с первого дня, а не после первого бага в метро. Всё остальное — детали)