Skip to content

Instantly share code, notes, and snippets.

@11111000000
Last active October 4, 2025 07:26
Show Gist options
  • Select an option

  • Save 11111000000/0245b3a490c0765c0dba2b38fa05b270 to your computer and use it in GitHub Desktop.

Select an option

Save 11111000000/0245b3a490c0765c0dba2b38fa05b270 to your computer and use it in GitHub Desktop.
Памятка по асинхронности в Emacs Lisp

Асинхронность в Emacs Lisp на практике: от модели к надёжным паттернам

Модель исполнения: почему в Emacs асинхронность выглядит так, а не иначе

В основе Emacs — однопоточная модель исполнения с «глобальной блокировкой» интерпретатора Lisp. Это означает, что обычный Lisp‑код выполняется последовательно и не прерывается конкурентной работой другого Lisp‑кода. Асинхронные события (вывод процессов, сетевые данные, таймеры) обрабатываются в «точках ожидания»: когда Emacs ждёт ввода, перерисовки, данных от процессов, или когда вы явно вызываете функции ожидания.

Такой дизайн приносит важные свойства. Он упрощает мышление: команда выполняется «атомарно», пользователь видит консистентное состояние буферов/окон, отладка предсказуемее. Он защищает редакторские структуры от гонок и убирает необходимость всюду ставить блокировки. Цена — отсутствие ускорения CPU‑задач «потоками Emacs Lisp» и необходимость явно уступать управление, чтобы асинхронные события исполнялись вовремя. Реальный параллелизм достигается через внешние процессы или отдельный Emacs (async.el).

Ключевые плюсы однопоточности в Emacs Lisp (в контексте асинхронности)

Эти принципы объясняют, почему в остальной части статьи акцент на процессах/сокетах, а не на «параллелизации» Lisp‑кода.

  • Простая модель исполнения. Команды run-to-completion, события исполняются лишь в точках ожидания; минимум неявных повторных входов в код.
  • Целостность редакторских структур. Буферы, окна, текстовые свойства не модифицируются конкурентно.
  • Меньше синхронизации. Нет повсеместных мьютексов вокруг редакторских примитивов.
  • Предсказуемый UI. Пользователь видит завершённые изменения, redisplay — в главном потоке.
  • Проще отладка. Edebug и трассировка работают над последовательным кодом.
  • Удобные динамические привязки. let‑связывания локальны к команде, без межпоточного «просачивания».
  • GC без межпоточных барьеров. Меньше накладных расходов.
  • Экосистема. Тысячи пакетов писались под эту модель, совместимость выше.
  • Параллелизм через процессы. Ресурсоёмкое — во внешние процессы или отдельный Emacs; изоляция повышает надёжность.

Карта местности и выбор инструмента

В Emacs три столпа асинхронности: внешние процессы/сокеты, таймеры, и кооперативные потоки Emacs Lisp. Почти всегда для сети и I/O выбирайте процессы/сокеты; для «сделать чуть позже» — таймеры; для координации ожиданий — потоки (аккуратно, не для CPU). Для реального параллелизма Lisp‑вычислений — отдельный Emacs через async.el. В конце статьи есть краткая «шпаргалка» выбора, но ниже вы найдёте подробные обоснования и паттерны.

Обзор встроенных средств асинхронности: базовые паттерны и мини‑примеры

В этом разделе — короткий ориентир: как Emacs исполняет асинхронные события и какие инструменты доступны «из коробки».

Как Emacs исполняет асинхронность (в двух словах)

  • Lisp исполняется в главном потоке по модели «команда до завершения».
  • Асинхронные события (фильтры/сентинелы, таймеры, URL‑колбэки) выполняются в точках ожидания: когда Emacs ждёт ввод/перерисовку/данные или вы явно ждёте.
  • Потоки Emacs Lisp кооперативные: в любой момент исполняется лишь один участок Lisp‑кода.
flowchart TD
  A[Пользователь/команда] --> B[Главный цикл Emacs]
  B --> C[Ожидание ввода/IO/перерисовки]
  C -->|данные от процесса| F[Фильтры процессов]
  C -->|изменение статуса| S[Сентинелы]
  C -->|сработали таймеры| T[Таймеры]
  C -->|готов HTTP ответ| U[URL callbacks]
  F --> B
  S --> B
  T --> B
  U --> B
Loading

Практика: держите обработчики максимально короткими; ресурсоёмкое выполняйте после завершения процесса или в отдельном процессе/async.el; обновления UI планируйте через run-at-time 0.

Процессы: make-process/start-process

Фильтр получает потоковый вывод, сентинел — события статуса (exit/signal). Кодировку задавайте опцией :coding или через set-process-coding-system.

;; -*- lexical-binding: t; -*-
(let ((proc
       (make-process
        :name "demo-echo"
        :buffer nil
        :command (list (or (executable-find "printf") "/usr/bin/printf") "hi")
        :noquery t
        :connection-type 'pipe
        :coding 'utf-8
        :filter (lambda (_p s) (message "[out] %s" s))
        :sentinel (lambda (_p ev) (message "[status] %s" (string-trim ev))))))
  proc)

Диаграмма жизненного цикла процесса:

sequenceDiagram
  participant E as Emacs (главный поток)
  participant P as Внешний процесс
  E->>P: make-process(:filter, :sentinel)
  P-->>E: stdout chunk (filter p chunk)
  E->>E: быстрый filter, накопление, минимум работы
  P-->>E: завершение (sentinel p ev)
  E->>E: cleanup ресурсов, UI через run-at-time 0
Loading

Таймеры: run-at-time и run-with-idle-timer

run-at-time запускает колбэк через заданное время (может повторяться). run-with-idle-timer — после периода бездействия пользователя. Повторяющиеся таймеры не «догоняют» пропуски.

;; Сработает один раз через секунду
(run-at-time 1 nil (lambda () (message "через 1 с")))

;; Каждые 3 секунды простоя пользователя
(run-with-idle-timer 3 t (lambda () (message "пинг в простое")))
sequenceDiagram
  participant E as Emacs
  E->>E: run-at-time delay repeat cb
  Note over E: Планирование t0+delay, затем +repeat
  E-->>E: cb исполняется в точках ожидания
  Note over E: Пропущенные тики не накапливаются
Loading

Потоки Emacs Lisp: кооперативная координация

Не ускоряют CPU, полезны для ожиданий/координации. UI обновляйте в главном потоке.

;; -*- lexical-binding: t; -*-
(make-thread
 (lambda ()
   (sleep-for 0.2)
   (run-at-time 0 nil (lambda () (message "из потока (UI в главном)")))))

accept-process-output: последовательное ожидание событий

Удобен в тестах/скриптах, когда нужно дождаться фильтров/сентинелов.

;; -*- lexical-binding: t; -*-
(let ((done nil))
  (make-process
   :name "echo2" :buffer nil
   :command (list (or (executable-find "printf") "/usr/bin/printf") "ok")
   :noquery t :connection-type 'pipe :coding 'utf-8
   :sentinel (lambda (_ _) (setq done t)))
  (while (not done)
    (accept-process-output nil 0.05)))

HTTP: url-retrieve (базовый стек)

Колбэк получает plist status, текущий буфер — ответ; закрывать буфер обязан вызывающий.

;; -*- lexical-binding: t; -*-
(require 'url)
(url-retrieve
 "https://example.org"
 (lambda (_status)
   (unwind-protect
       (progn
         (goto-char (point-min))
         (re-search-forward "\r?\n\r?\n" nil t)
         (message "Тело: %d байт" (- (point-max) (point))))
     (kill-buffer (current-buffer)))))

Реальный параллелизм Lisp через async.el

async.el запускает вычисления в отдельном процессе Emacs; результат вернётся в колбэк.

(require 'async)
(async-start
 (lambda () (sleep-for 0.2) (* 6 7))
 (lambda (r) (message "Результат: %s" r)))

Мини‑шпаргалка (TL;DR)

  • I/O и параллелизм — через внешние процессы. В фильтрах минимум работы; разбор — в sentinel; UI — через run-at-time 0.
  • Кодировка процессов: :coding ‘utf-8; :connection-type ‘pipe.
  • read-process-output-max (Emacs 27+): глобально; поднимите один раз (1–4 MiB).
  • Таймеры не «догоняют» график; idle‑таймеры зависят от бездействия пользователя.
  • Потоки Emacs Lisp — координация ожиданий, не ускорение CPU.
  • Для ресурсоёмкого — async.el (отдельный Emacs) или внешние утилиты.
  • В обработчиках — condition-case и очистка ресурсов (unwind-protect); добавляйте таймауты и отмену.

Quick start: три базовых рецепта

Ниже — три часто встречающихся задачи. Все фрагменты предполагают lexical-binding и аккуратную очистку ресурсов.

Рецепт 1: Запустить внешнюю команду, собрать потоковый вывод, гарантированно закрыть ресурсы

;; -*- lexical-binding: t; -*-
(require 'subr-x)
(let* ((buf (generate-new-buffer " *rg*"))
       ;; В большом I/O стоит поднять лимит чанков (Emacs 27+)
       (old-rpom read-process-output-max)
       (proc nil)
       (done nil)
       (timeout-timer nil))
  ;; Глобально увеличим лимит чтения; восстановим в sentinel/при ошибке
  (setq read-process-output-max (* 1024 1024)) ; 1 MiB
  (condition-case err
      (setq proc
            (make-process
             :name "ripgrep"
             :buffer buf
             :command (list (or (executable-find "rg")
                                "/nix/store/...-ripgrep-13.0.0/bin/rg")
                            "--line-number" "--color" "never" "TODO" default-directory)
             :noquery t
             :connection-type 'pipe
             :coding 'utf-8
             :filter (lambda (_p chunk)
                       ;; Быстрый фильтр: минимум аллокаций
                       (when (buffer-live-p buf)
                         (with-current-buffer buf
                           ;; Отключим undo для потокового буфера
                           (setq buffer-undo-list t)
                           (goto-char (point-max))
                           (insert chunk))))
             :sentinel (lambda (p ev)
                         ;; Sentinel может вызываться несколько раз — проверяем финальный статус
                         (when (memq (process-status p) '(exit signal))
                           (setq done t)
                           (let ((status (process-exit-status p)))
                             (message "[%s] завершился: %s (exit=%d)"
                                      (process-name p) (string-trim ev) status))
                           (when timeout-timer (cancel-timer timeout-timer))
                           (when (buffer-live-p buf)
                             (with-current-buffer buf
                               (message "Размер вывода: %d байт" (buffer-size)))
                             (kill-buffer buf))
                           ;; Восстановим глобальный лимит
                           (setq read-process-output-max old-rpom))))))
    (error
     (setq read-process-output-max old-rpom)
     (signal (car err) (cdr err))))

  ;; Таймаут на случай зависания процесса
  (setq timeout-timer
        (run-at-time
         15 nil
         (lambda ()
           (unless done
             (message "[%s] таймаут — прерываю" (process-name proc))
             (when (process-live-p proc)
               ;; Сначала мягко, затем жёстко
               (interrupt-process proc)
               (run-at-time
                0.1 nil
                (lambda ()
                  (when (process-live-p proc)
                    (delete-process proc))))))))))

Ключевые детали: фильтр и сентинел установлены при создании; кодировка установлена; лимит чанков увеличен; предусмотрен таймаут и аккуратное завершение. Для больших объёмов вывод лучше агрегировать в буфере и обрабатывать затем, чем делать ресурсоёмкую работу внутри фильтра.

Рецепт 2: HTTP-запрос, корректное управление буфером и ошибка в статусе

;; -*- lexical-binding: t; -*-
(require 'url)

(url-retrieve
 "https://example.org/"
 (lambda (status)
   (let ((err (plist-get status :error)))
     (if err
         (message "HTTP ошибка: %S" err)
       (unwind-protect
           (progn
             ;; Текущий буфер — буфер ответа
             (goto-char (point-min))
             (if (and (boundp 'url-http-end-of-headers) url-http-end-of-headers)
                 (goto-char url-http-end-of-headers)
               (re-search-forward "\r?\n\r?\n" nil t))
             (let ((body (buffer-substring-no-properties (point) (point-max))))
               (message "Получено %d байт (учтите: 4xx/5xx — не транспортная ошибка)" (length body))))
         ;; Обязательно закрыть буфер
         (when (buffer-live-p (current-buffer))
           (kill-buffer (current-buffer)))))))

С url-retrieve колбэк получает plist status; текущий буфер — буфер ответа, который вы обязаны закрыть. Для более удобного API и промисов смотрите plz или request.el.

Рецепт 3: Периодическая задача с коалесценцией (не запускаем новый цикл, если предыдущий ещё идёт)

;; -*- lexical-binding: t; -*-
(require 'async)
(let ((running nil))
  (run-at-time
   2 5
   (lambda ()
     (when (not running)
       (setq running t)
       (message "Старт периодической задачи")
       (async-start
        (lambda ()
          (require 'subr-x)
          (sleep-for 1.5)
          (list :pid (emacs-pid) :ts (current-time-string)))
        (lambda (res)
          (setq running nil)
          (message "Готово: %S" res)))))))

Этот шаблон устраняет накопление параллельных запусков. Для ресурсоёмкого кода async-start даст реальный параллелизм (отдельный Emacs‑процесс).

Как писать асинхронный Emacs Lisp: процессы, сеть и тонкости жизненного цикла

Внешние процессы — главный инструмент для I/O и параллелизма. Делегируйте работу ОС: запуск, пайпы, планировщик, параллельная обработка — всё это отлажено десятилетиями. В Emacs вы создаёте процесс и получаете обратную связь через два обработчика: фильтр (на потоковый вывод) и сентинел (на изменения статуса: старт, стоп, завершение, сигнал).

Важно ставить и фильтр, и сентинел сразу в make-process. Сентинел может сработать очень рано (например, процесс мгновенно завершился ошибкой), и вы рискуете пропустить событие, если установить его позже. Фильтр получает произвольные чанки — границы строк не гарантируются, Unicode может резаться посередине; для корректной декодировки настройте кодировки через :coding в make-process или set-process-coding-system сразу после создания.

read-process-output-max (с Emacs 27+) определяет максимальный размер чанка, который Emacs прочитает из пайпа за раз. Для высокопроизводительных команд имеет смысл поднять значение до сотен килобайт или мегабайтов, чтобы снизить системные вызовы.

Обработчики должны быть быстрыми и безопасными. Любая ошибка в фильтре/сентинеле по умолчанию уйдёт в Messages. На этапе разработки полезно (setq debug-on-error t) и/или оборачивать обработчики в condition-case, чтобы логировать исключения и гарантировать очистку ресурсов. Планируйте ресурсоёмкую работу после завершения процесса (в сентинеле), а UI‑обновления — через run-at-time 0, чтобы не мешать вводу/redisplay.

Наконец, помните, что accept-process-output не просто «ждёт данные». Внутри него Emacs исполняет фильтры, сентинелы и таймеры. Это удобно для тестов и последовательных сценариев, но это означает возможность повторного входа: ваш код может быть прерван и повторно вызван колбэками. Защищайте критические секции мьютексами или флагами занятости, не полагайтесь на глобальные переменные без дисциплины.

Ниже — расширенный пример с backpressure: мы не даём буферу расти безмерно, обрезая начало, сохраняя последние N байт.

;; -*- lexical-binding: t; -*-
(require 'subr-x)
(let* ((buf (generate-new-buffer " *stream*"))
       (max-size (* 2 1024 1024)) ; 2 MiB храним в буфере
       (old-rpom read-process-output-max)
       (proc nil))
  ;; Временно увеличим лимит чтения; восстановим в sentinel
  (setq read-process-output-max (* 512 1024))
  (condition-case err
      (setq proc
            (make-process
             :name "long-cat"
             :buffer buf
             :command (list (or (executable-find "bash") "/nix/store/...-bash/bin/bash")
                            "-lc"
                            "yes 'data line' | head -n 200000")
             :noquery t
             :connection-type 'pipe
             :coding 'utf-8
             :filter (lambda (p chunk)
                       (condition-case err
                           (when (buffer-live-p buf)
                             (with-current-buffer buf
                               ;; Отключим undo для потокового буфера
                               (setq buffer-undo-list t)
                               (goto-char (point-max))
                               (insert chunk)
                               ;; Backpressure: если буфер разросся — обрежем начало
                               (when (> (buffer-size) max-size)
                                 (save-excursion
                                   (goto-char (- (point-max) max-size))
                                   (delete-region (point-min) (point))))))
                         (error (message "[%s filter err] %S" (process-name p) err))))
             :sentinel (lambda (p ev)
                         (when (memq (process-status p) '(exit signal))
                           (message "[%s] %s" (process-name p) (string-trim ev))
                           (when (buffer-live-p buf)
                             (kill-buffer buf))
                           ;; Восстановим глобальный лимит
                           (setq read-process-output-max old-rpom)))))
    (error
     (setq read-process-output-max old-rpom)
     (signal (car err) (cdr err))))
  proc)

Таймеры: «сделать позже» и «когда пользователь бездействует»

Таймеры — удобный механизм запланировать действие позже или после периода бездействия (idle). Важно понимать, что таймеры исполняются в том же главном потоке, что и остальной Lisp‑код. Они не дают параллелизма и не ускоряют вычисления; они лишь «вклинивают» ваш колбэк в точки ожидания. Если основной код занялся долгой синхронной работой и не уступает управление, таймер сработает позже.

run-at-time принимает как числа (секунды), так и строковые спецификации («2 sec», «10:00»). Повторяющиеся таймеры (repeat) запускаются сериями, но не «догоняют» пропуски: если один запуск задержался, Emacs не сделает сразу несколько, чтобы наверстать график. idle‑таймеры (run-with-idle-timer) измеряют именно пользовательское бездействие: любой ввод «сбивает» таймер.

Ниже — пример, где по таймеру обновляется UI, но ресурсоёмкая работа вынесена в процесс; мы также коалесцируем запуски.

;; -*- lexical-binding: t; -*-
(require 'subr-x)
(let ((running nil))
  (run-with-idle-timer
   3 t
   (lambda ()
     (when (not running)
       (setq running t)
       (condition-case err
           (let* ((buf (generate-new-buffer " *du*"))
                  (proc
                   (make-process
                    :name "du"
                    :buffer buf
                    :command (list (or (executable-find "du") "/nix/store/...-du/bin/du")
                                   "-sh" default-directory)
                    :noquery t
                    :connection-type 'pipe
                    :coding 'utf-8
                    :sentinel (lambda (p _)
                                (when (memq (process-status p) '(exit signal))
                                  (let (res)
                                    (when (buffer-live-p buf)
                                      (with-current-buffer buf
                                        (setq res (string-trim (buffer-string))))
                                      (kill-buffer buf))
                                    ;; UI обновляем в «следующем ти́ке» главного потока
                                    (run-at-time
                                     0 nil
                                     (lambda (text)
                                       (message "Размер каталога: %s" text))
                                     (or res "<нет данных>")))
                                  (setq running nil)))))))
             ;; Ничего не делаем в фильтре; вся логика — в sentinel
             )
         (error
          (setq running nil)
          (message "Ошибка запуска du: %S" err)))))))

«Асинхронный run-at-time» на базе async.el: реальный параллелизм Lisp‑кода

Пакет async.el запускает вычисления в отдельном процессе Emacs. Это даёт настоящий параллелизм относительно главного Emacs: независимый GC, отсутствуют паузы UI, изоляция ошибок. В колбэке вы получаете результат, сериализованный prin1/read.

На NixOS важно обеспечить дочернему Emacs доступ к тем же пакетам. Самый надёжный путь — собрать emacsWithPackages, чтобы и главный, и дочерний Emacs были одинаковыми. Либо явно настраивать EMACSLOADPATH/exec-path. В примере ниже показано, как задать колбэк, коалесцировать периодические запуски и обрабатывать ошибки.

;; -*- lexical-binding: t; -*-
(require 'async)

(cl-defun my-async-run-at-time (time repeat form &key callback coalesce)
  "Как run-at-time, но FORM — самодостаточный sexp, исполняемый в отдельном Emacs (async.el).
TIME/REPEAT — как в run-at-time. CALLBACK получает результат или символ :error."
  (let ((running nil)
        (timer nil))
    (setq timer
          (run-at-time
           time repeat
           (lambda ()
             (when (or (not coalesce) (not running))
               (setq running t)
               (condition-case _
                   (async-start
                    `(lambda ()
                       (condition-case err
                           (progn ,form)
                         (error (cons :error (error-message-string err)))))
                    (lambda (res)
                      (setq running nil)
                      (when callback
                        (if (and (consp res) (eq (car res) :error))
                            (funcall callback :error)
                          (funcall callback res)))))
                 (error
                  (setq running nil)
                  (when callback (funcall callback :error))))))))
    ;; Вернём «ручку»: (timer . cancel-fn)
    (cons timer (lambda () (cancel-timer timer)))))

;; Пример использования:
(my-async-run-at-time
 2 nil
 '(progn
    (require 'subr-x)
    (sleep-for 1)
    (mapcar #'upcase '("a" "b" "c")))
 :callback (lambda (res)
             (message "async готов: %S" res))
 :coalesce t)

Для внешних команд используйте make-process/start-process и таймеры — это эффективнее, чем гонять команду через async‑Emacs без необходимости.

Потоки Emacs Lisp: кооперативные, не для ускорения CPU

Потоки (make-thread) в Emacs — кооперативные: за интерпретатор держится глобальная блокировка. Они не ускоряют ресурсоёмкие задачи, но полезны, когда нужно «ждать что-то» без блокировки главного потока, координировать таймауты, организовать ожидание условных переменных. Любая работа с буферами/окнами/кадрами должна выполняться в главном потоке. Используйте run-at-time 0 для безопасного UI‑обновления.

Ниже — поток, который делает имитацию вычислений, сигналит главному потоку через run-at-time 0 и синхронизируется мьютексом:

;; -*- lexical-binding: t; -*-
(let ((mtx (make-mutex "demo"))
      (result nil))
  (make-thread
   (lambda ()
     (dotimes (_ 5)
       (sleep-for 0.1)
       (thread-yield))
     (mutex-lock mtx)
     (setq result "готово")
     (mutex-unlock mtx)
     ;; UI — в главном потоке
     (run-at-time 0 nil (lambda (r) (message "Результат: %s" r)) result)))
  ;; В основной нити можно периодически «проверять» результат
  (run-at-time
   0.3 nil
   (lambda ()
     (mutex-lock mtx)
     (unwind-protect
         (message "Промежуточно: %S" result)
       (mutex-unlock mtx)))))

Помните: даже с потоками ресурсоёмкое CPU лучше выносить во внешние процессы или async.el.

Реальный параллелизм

Реальный параллелизм в Emacs достигается двумя путями.

1) Внешние процессы: надёжный способ разгрузить Emacs

Вы запускаете внешнюю команду и обрабатываете вывод/статус через фильтры/сентинелы. Это лучшая практика для сетевого и файлового I/O, индексирования, grep, преобразования данных. Для отмены используйте delete-process или посылайте сигналы (interrupt-process). Таймауты — через таймеры.

;; -*- lexical-binding: t; -*-
(require 'subr-x)
(let (proc timer)
  (setq timer
        (run-at-time
         1 nil
         (lambda ()
           (setq proc
                 (make-process
                  :name "ping"
                  :buffer (generate-new-buffer " *ping*")
                  :command (list (or (executable-find "ping")
                                     "/nix/store/...-iputils/bin/ping")
                                 "-c" "3" "example.org")
                  :noquery t
                  :connection-type 'pipe
                  :coding 'utf-8
                  :filter (lambda (_ out) (message "[ping] %s" out))
                  :sentinel (lambda (p ev)
                              (message "[ping %s] %s" (process-status p) (string-trim ev))
                              (when-let ((b (process-buffer p)))
                                (when (buffer-live-p b) (kill-buffer b)))))))))
  ;; Пример отмены до старта:
  ;; (cancel-timer timer)
  ;; Пример отмены после старта:
  ;; (when (and proc (process-live-p proc)) (delete-process proc))
  )

2) Отдельный Emacs через async.el: параллельные Lisp‑вычисления

async-start запускает ваш thunk в дочернем Emacs и вернёт результат в колбэк. Для внешней команды есть async-start-process: удобный способ дождаться завершения и обработать результат.

;; -*- lexical-binding: t; -*-
(require 'async)
(require 'subr-x)

(async-start
 (lambda ()
   (sleep-for 0.5)
   (mapcar #'upcase '("a" "b" "c")))
 (lambda (result)
   (message "Из async: %S" result)))

;; Вариант с процессом:
(async-start-process
 "uname" (or (executable-find "uname") "/usr/bin/uname") '("-a")
 (lambda (buf)
   (unwind-protect
       (when (buffer-live-p buf)
         (with-current-buffer buf
           (message "uname: %s" (string-trim (buffer-string)))))
     (when (buffer-live-p buf) (kill-buffer buf)))))

Под NixOS убедитесь, что дочерний Emacs видит нужные пакеты. Рекомендуется использовать одинаковый emacsWithPackages или явно передавать EMACSLOADPATH/exec-path в process-environment. Внешним командам передавайте абсолютные пути из nix store или используйте (executable-find …), заранее обеспечив PATH.

HTTP: url-retrieve, plz, request.el

url-retrieve — базовый API Emacs для HTTP. Колбэк получает (status plist), текущий буфер — ответ, парсинг/закрытие на вашей стороне. Для простых сценариев это достаточно, но API низкоуровневый.

plz (Emacs 27.1+) — современная обёртка с удобными опциями (асинхронность, декодирование, «as string/json/buffer»). Уточняйте минимальную версию в README пакета. Ниже пример с plz и отменой через объект запроса.

;; -*- lexical-binding: t; -*-
;; Требуется установленный plz; минимальную версию Emacs уточняйте в README (обычно 27.1+)
(require 'plz)

(let (req)
  (setq req
        (plz 'get "https://httpbin.org/get"
          :as 'json
          :then (lambda (json)
                  (message "origin=%s" (alist-get 'origin json)))
          :else (lambda (e)
                  (message "Ошибка plz: %S" e))))
  ;; Пример отмены:
  ;; (plz-cancel req)
  )

request.el — популярная библиотека с промисами/колбэками и широким API; она тянет зависимости и не всегда максимально производительна, но удобна для «быстрых» интеграций. Выбор между url-retrieve, plz и request делайте по требованиям к производительности, типизации ответа и необходимым возможностям (таймауты, отмена, заголовки, TLS).

Ошибки, отладка, тестирование асинхронного кода

В фильтрах/сентинелах/таймерах ошибки по умолчанию не «проталкиваются» вверх по стеку вызовов. В разработке включайте (setq debug-on-error t), используйте condition-case в обработчиках и логируйте ошибки в Messages или собственный лог. Для тестирования удобно сочетать ert, with-timeout и accept-process-output: вы запускаете процесс, ждёте события и проверяете инварианты. Помните: accept-process-output внутри исполняет фильтры/таймеры.

;; -*- lexical-binding: t; -*-
(require 'ert)

(ert-deftest my-process-test ()
  (let* ((buf (generate-new-buffer " *t*"))
         (done nil)
         (proc (make-process
                :name "echo"
                :buffer buf
                :command (list (or (executable-find "printf") "/usr/bin/printf")
                               "hello")
                :noquery t
                :connection-type 'pipe
                :coding 'utf-8
                :sentinel (lambda (p _)
                            (when (memq (process-status p) '(exit signal))
                              (setq done t))))))
    (unwind-protect
        (with-timeout (2 (ert-fail "timeout")) ; таймаут — тест провален
          (while (not done)
            (accept-process-output proc 0.05)))
      (when (and proc (process-live-p proc)) (delete-process proc))
      (when (buffer-live-p buf) (kill-buffer buf)))
    (should done)))

Безопасность сети и TLS

Emacs использует GnuTLS (или схожие бэкенды) для TLS. Для повышения строгости проверьте настройки gnutls-verify-error и gnutls-log-level, используйте pinning сертификатов там, где это разумно, учитывайте прокси (url-proxy-services) и переменные окружения (HTTP(S)_PROXY, NO_PROXY). Для чувствительных запросов лучше предпочесть plz с явными параметрами TLS, чем низкоуровневые ручные вызовы.

Практические следствия (собрание правил, которые окупаются каждый день)

Свод правил повторяет исходные тезисы, но снабжён контекстом. Это те привычки, которые делают асинхронный Emacs‑код отзывчивым и устойчивым.

  • Делайте долгие операции так, чтобы явно уступать управление. В синхронном коде это accept-process-output/sit-for/run-at-time 0 для «дыхания» UI. В идеале — переносите ресурсоёмкое в процесс/async.el.
  • Минимизируйте работу в фильтрах/сентинелах/таймерах. В фильтре — накапливайте, в сентинеле — завершающий разбор, в UI — через run-at-time 0. Оборачивайте обработчики в condition-case.
  • I/O — через процессы/сокеты. Ресурсоёмкое — во внешний процесс или отдельный Emacs. Не пытайтесь «ускорить» лисп‑потоками.
  • Периодические задачи — коалесцируйте. Храните флаг «занято», используйте мьютексы, избегайте параллельных экземпляров.
  • Управляйте ресурсами. Закрывайте временные буферы, отменяйте таймеры, добавляйте таймауты и «мягкое» завершение процессов.
  • Настраивайте кодировки и размеры чанков. :coding ‘utf-8 и увеличенный read-process-output-max снижают «многошумность» I/O.
  • Планируйте UI‑обновления в главный поток. Даже если вы уверены — привычка окупится при сложных сценариях.

Отличия асинхронности Emacs Lisp от модели JavaScript

Различия полезно помнить, если вы мысленно переносите привычки из JS.

В JS есть event loop с макро- и микрозадачами, встроенные промисы и синтаксис async/await. Код исполняется run-to-completion, и колбэки не прерывают синхронный JS; очередь микрозадач (then/await) имеет строгие гарантии порядка.

В Emacs «командный цикл» — аналог макрозадач. Микрозадачной очереди нет: обработчики процессов/таймеров исполняются в точках ожидания, и их порядок не завязан на «микротики». Промисы — не часть языка, но доступны библиотеками (promise.el, deferred.el, aio.el). Потоки есть, но кооперативные и с глобальной блокировкой — не для ускорения CPU. Для реального параллелизма используйте процессы/async.el. HTTP‑стек в Emacs — часто колбэчный; url-retrieve низкоуровнев, plz/request дают более удобный интерфейс. Исключения в обработчиках не превращаются в «rejected promise» — используйте condition-case и логирование. Отмена — это cancel-timer, delete-process/plz-cancel, а также проектирование идемпотентных задач.

Удобные библиотеки для асинхронного стиля

  • request.el — HTTP с колбэками/промисами, богатый API.
  • plz — современный HTTP (Emacs 27.1+), простой интерфейс, отмена, «as json/string/buffer».
  • deferred.el/promise.el — промисы и комбинаторы.
  • aio.el — «async/await»-подобный синтаксис на промисах.

Выбор делайте по требованиям к версии Emacs, удобству API, производительности и контролю над ошибками/отменой.

Когда что выбирать (короткая карта решений)

  • Потоковый ввод/вывод, интеграция CLI, сеть на сокетах — процессы + фильтры/сентинелы.
  • «Сделать позже» и лёгкие действия — таймеры. Ресурсоёмкое по таймеру — через async.el или внешний процесс.
  • Координация ожиданий без блокировки UI — потоки Emacs Lisp; UI‑работы планируйте в главный поток.
  • Ресурсоёмкие CPU‑задачи — внешний процесс или отдельный Emacs (c.el).
  • Периодические задачи (repeat) — коалесценция, флаг «занято», отмена, таймауты.

Справка в Emacs

  • M-x info → Elisp → Processes (Asynchronous Processes) — всё о make-process, фильтрах, сентинелах.
  • M-x info → Elisp → Timers — планирование, idle‑таймеры, повторные таймеры.
  • M-x info → Elisp → Threads — потоки, мьютексы, условные переменные.

Примечания к примерам (актуальные мелочи, которые экономят часы)

  • Всегда ставьте -*- lexical-binding: t; -*- в начале файла/блока.
  • Задавайте :filter/:sentinel прямо в make-process; sentinel может сработать очень рано.
  • Фильтры получают произвольные чанки; настраивайте кодировку (set-process-coding-system или :coding), учитывайте возможное рассечение Unicode.
  • Увеличивайте read-process-output-max (Emacs 27+) для производительного I/O; локальное let‑связывание не действует на асинхронное чтение — используйте глобальный setq или setq с восстановлением значения в sentinel; попробуйте :connection-type ‘pipe, это обычно быстрее PTY.
  • Важно: значение read-process-output-max глобально для всей сессии. Если разные части конфигурации меняют его динамически, возможны «гонки настроек». Практика: поднимите его один раз в init (например, до 1–4 MiB) и не меняйте на лету, либо строго документируйте места изменения и восстановление в sentinel.
  • accept-process-output исполняет фильтры/сентинелы/таймеры — учитывайте возможность повторного входа, защищайте критические секции.
  • Обработчики оборачивайте в condition-case, включайте debug-on-error при разработке, логируйте ошибки.
  • В async.el дочерний Emacs должен видеть зависимости: на NixOS используйте emacsWithPackages, задайте EMACSLOADPATH/exec-path, передавайте абсолютные пути к внешним утилитам из nix store или подготовьте PATH.
  • Для периодики используйте коалесценцию (флаг «занято»/мьютекс).
  • Временные буферы и ресурсы закрывайте в sentinel/ finally‑секции (unwind-protect). Добавляйте таймауты и «мягкое» прерывание процессов.
  • Для очень большого вывода реализуйте backpressure: обрезка буфера, ring‑структуры, запись на диск (временный файл) вместо накопления в памяти.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment