Защита от повторного запуска приложения Windows — именованный Mutex и активация окна при повторном запуске

· · Защита от повторного запуска, Mutex, Windows, .NET, C#, SetForegroundWindow, Удалённый рабочий стол, Именованные каналы, Разработка Windows, Техническая консультация

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

Однако у этой простой на первый взгляд реализации есть немало сопутствующих подводных камней: ловушка пространства имён, из-за которой защита перестаёт работать в среде удалённого рабочего стола; правила освобождения, специфичные для Mutex и отличающие его от других объектов синхронизации; и задача проектирования — как вывести на передний план обнаруженный уже запущенный экземпляр, — упирающаяся в ограничения Win32 на управление окном переднего плана. В этой статье мы последовательно разберём и основы обнаружения повторного запуска через именованный Mutex, и все сопутствующие решения, которые нужны на практике.

1. Сначала вывод

  • Базовая форма обнаружения повторного запуска — new Mutex(true, name, out bool createdNew). Даже если Mutex с таким именем уже существует, исключение не возникает — просто createdNew становится false, и по этому значению принимается решение.1
  • Если не указать префикс в имени, объект по умолчанию создаётся в пространстве имён Local\ (ограниченном сеансом). В среде удалённого рабочего стола, где один и тот же пользователь может иметь несколько сеансов, каждый сеанс получает собственный Mutex, и защита от повторного запуска перестаёт работать. Чтобы свести всё к одному экземпляру независимо от сеанса, обязателен префикс Global\.23
  • Mutex можно освободить только из того же потока, который его получил. Вызов ReleaseMutex из другого потока приводит к ApplicationException. Если владеющий процесс завершается без освобождения, следующему получившему объект потоку выбрасывается AbandonedMutexException — но это сигнал о том, что само ожидание завершилось успешно, и правильно не игнорировать его, а сначала проверить целостность состояния, а уже потом использовать объект.45
  • После того как выяснилось, что «экземпляр уже запущен», основная задача — вывести окно существующего экземпляра на передний план. ОС ограничивает вызов SetForegroundWindow процессами, не являющимися процессом переднего плана, поэтому простой вызов чаще всего терпит неудачу, и дело ограничивается мерцанием кнопки на панели задач.6 Стандартный приём — передать «право устанавливать передний план», которым обладает только что запущенный второй процесс, существующему экземпляру через AllowSetForegroundWindow.7
  • Чтобы передать существующему экземпляру аргументы запуска (например, путь к файлу, который нужно открыть), стандартный приём — переслать их через именованный канал. Сравнение способов IPC мы оставляем статье «Как выбрать способ межпроцессного взаимодействия в Windows», а в этой статье сосредоточимся на проектировании цепочки «обнаружение → уведомление → активация».
  • Консольные приложения и службы — не те объекты, к которым дизайн этой статьи применим напрямую. У служб иная логика с самого начала, так как SCM в принципе запускает только один экземпляр службы с данным именем (глава 8).

2. Основы обнаружения через именованный Mutex

Mutex — это объект ядра: если создать его с именем, другие процессы, знающие это имя, могут ссылаться на тот же объект. Для обнаружения повторного запуска используется именно это «совместное использование имени».

using var mutex = new Mutex(initiallyOwned: true, name: MutexName, out bool createdNew);
if (!createdNew)
{
    // Уже запущен
    return;
}
// Этот поток владеет Mutex только тогда, когда createdNew равен true

Здесь стоит держать в голове два момента.

  • Даже если Mutex с таким же именем уже существует, исключения не возникает. В createdNew попадает false, и просто возвращается ссылка на существующий объект. Решение всегда принимайте по createdNew.1
  • initiallyOwned: true действует только тогда, когда объект удалось создать заново. Если он уже существовал (createdNew == false), этот поток автоматически владельцем не становится. Для обнаружения повторного запуска этот побочный эффект не проблема, но чтобы предотвратить аварию «второй процесс по ошибке вызывает ReleaseMutex», безопаснее в ветке с createdNew == false вообще не трогать Mutex и сразу завершать обработку.1

Имя принято составлять как строку с внедрённым GUID продукта, чтобы избежать конфликтов с приложениями других разработчиков (например, "KomuraSoft.MyApp.SingleInstance.{3F1E2B10-...}"). Как и в случае со сквоттингом имён каналов, пространство имён именованных объектов ядра видно и другим процессам на машине, так что есть смысл выбирать имя, которое трудно угадать.

3. Global\ и Local\ — что происходит при пересечении сеансов

Пространство имён объектов ядра Windows делится на отдельные пространства для каждого сеанса и на единое глобальное пространство, общее для всей системы. Когда создаётся именованный Mutex без указанного префикса, он по умолчанию создаётся в пространстве имён сеанса вызывающего кода (Local\), и только с префиксом Global\ — в пространстве, общем для всех сеансов.3 Класс Mutex в .NET следует тому же поведению, и в документации прямо указано: «именованный Mutex без префикса по умолчанию использует Local\».2

Проблема возникает в таких сценариях.

  • Пользователь, вошедший в рабочую административную машину напрямую с консоли, дополнительно подключается к ней же ещё одним сеансом через удалённый рабочий стол (в эксплуатации это обычное дело).
  • Один и тот же пользователь многократно отключается и подключается заново по RDP, и при каждом переподключении назначается новый идентификатор сеанса.

Если создавать Mutex в Local\ (без префикса), эти случаи трактуются как разные сеансы, и защита от повторного запуска работает в каждом сеансе независимо. То есть возникает баг «один и тот же пользователь спокойно запускает копию из второго сеанса» — защита от повторного запуска не срабатывает так, как задумано. И наоборот: для обычного использования на одном компьютере, где предполагается только один сеанс, Local\ (значение по умолчанию) реального вреда не приносит.

Общие вопросы проектирования для машин, где сосуществуют несколько пользователей, разобраны и в статье «Введение в профили пользователей Windows», но применительно именно к теме этой статьи нужна осторожность. Global\ — это единое пространство имён, общее для всех пользователей и всех сеансов на машине, и оно автоматически не означает «один экземпляр на пользователя». Если просто добавить Global\ к фиксированному имени вроде Global\KomuraSoft.MyApp.SingleInstance, то пока приложение запущено у пользователя A, запуск у пользователя B тоже будет заблокирован тем же самым Mutex — фактически получается «один экземпляр на всю машину» (третья строка таблицы из главы 5). Если нужно реализовать «один экземпляр на пользователя, но объединяя все сеансы этого пользователя», требуется добавить к Global\ ещё и пользовательский идентификатор (например, SID), встроенный в имя. И наоборот, если нужно ограничиться одним экземпляром на всю машину (общий доступ для всех пользователей — это и есть цель), можно оставить Global\ без SID.

4. Правила освобождения Mutex — владеющий поток и AbandonedMutexException

У Mutex есть ограничение, которого нет у других объектов синхронизации вроде Semaphore или AutoResetEvent: правило, принудительно привязывающее объект к идентификатору потока — освободить его можно только из того же потока, который его получил.2 Вызов ReleaseMutex из другого потока приводит к ApplicationException («вызывающий поток не владеет мьютексом»).4

В коде, использующем async/await в .NET, выполнение после await может возобновиться на другом потоке пула потоков. Будьте осторожны: код вида «получить Mutex, сразу вставить асинхронную операцию, а освободить его уже в продолжении» может незаметно нарушить это ограничение. Для обнаружения повторного запуска безопаснее держать и получение, и освобождение внутри короткого синхронного кода.

Ещё один момент, за которым нужно следить, — отказ от владения (abandoned). Если поток, владевший Mutex, завершается без вызова ReleaseMutex (процесс упал, завершился из-за необработанного исключения и т. п.), этот Mutex переходит в состояние «покинутого». Следующему потоку, получившему этот Mutex, выбрасывается AbandonedMutexException, но это исключение означает, что само ожидание завершилось успешно, и вызывающая сторона уже владеет объектом.5 При чистом обнаружении повторного запуска это обычно не встречается (объект получают и держат без освобождения вплоть до завершения процесса), но если тот же Mutex используется и для других задач взаимного исключения, обрабатывать его нужно так, учитывая, что защищаемое состояние могло оказаться повреждённым:

try
{
    if (mutex.WaitOne(TimeSpan.FromSeconds(5)))
    {
        // Обычная обработка
    }
}
catch (AbandonedMutexException)
{
    // Ожидание завершилось успешно, и этот поток уже владеет объектом.
    // Нужно проверить защищаемое состояние перед использованием либо безопасно инициализировать его заново
}

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

При этом на пути штатного завершения ReleaseMutex стоит вызывать явно в блоке finally. Когда владеющий поток исчезает вместе с завершением процесса, Mutex считается «покинутым», поэтому без явного освобождения при следующем запуске возникнет ненужный AbandonedMutexException.

5. Таблица решений — в каком масштабе использовать Mutex

От того, «в каком масштабе» нужно предотвращать повторный запуск, зависит и проектирование пространства имён, и проектирование канала.

Масштаб Предполагаемый сценарий Пространство имён Mutex Передача аргументов запуска Замечания
Сеанс (по умолчанию) Обычное использование десктопа без RDP, либо когда достаточно «одного экземпляра на сеанс» Без префикса (т. е. Local\) CurrentUserOnly плюс включение ID сеанса в имя канала В окружении, где используется RDP, запуск из другого сеанса предотвратить нельзя. Если у одного пользователя несколько сеансов, без ID сеанса в имени канала возникает конфликт фиксированных имён (именованные каналы не подчиняются пространству имён сеансов, в отличие от Mutex)
Пользователь (через сеансы) Один пользователь чередует консоль и RDP, повторяет подключения по RDP Global\ + SID пользователя в имени + явный ACL через MutexSecurity CurrentUserOnly (определяется по SID пользователя, поэтому работает и через сеансы) Случай, наиболее востребованный на практике. Если оставить только Global\ без SID, поведение непреднамеренно станет общемашинным (следующая строка). SID не является секретной информацией пользователя, поэтому в средах с общими ПК/RDS стоит также предусмотреть сквоттинг имени со стороны других пользователей и защититься через ACL
Машина (все пользователи) По лицензии допускается не более одного экземпляра на машину, либо общий ресурс нужно эксклюзивно предоставлять всем пользователям Global\ (без пользовательской информации) + явный ACL через MutexSecurity Без CurrentUserOnly, разрешённых пользователей явно указывают через PipeSecurity В средах Remote Desktop Services, где одновременно входят несколько пользователей, такое поведение часто оказывается нежелательным для бизнеса — стоит сверить с требованиями. Не пересылайте аргументы запуска другого пользователя напрямую в окно первого пользователя как есть. Это может привести к утечке пути к файлу или к открытию чужого документа в сеансе не того пользователя — запросы от других пользователей стоит либо отклонять, либо перепроектировать через брокера без UI

Дополнительно: именованные каналы, в отличие от Mutex, не подчиняются сеансовому пространству имён Local\/Global\ и по умолчанию доступны через границы сеансов. Иными словами, даже без CurrentUserOnly само соединение дотянется до существующего экземпляра в другом сеансе, если совпадает имя канала. Здесь CurrentUserOnly — это вопрос не достижимости, а авторизации: контроль доступа, сужающий «чьи подключения разрешать» до текущего пользователя (и того же уровня повышения прав). Если его не указать, дескриптор безопасности по умолчанию даёт право на чтение и Everyone, поэтому дотянутся и подключения от непредусмотренных других пользователей. В конструкции, где «пользователь, через сеансы» реализуется через Mutex с Global\ + SID, разумно добавить CurrentUserOnly и к каналу уведомлений, сузив источник подключения до того же «только целевого пользователя», что и у Mutex. К счастью, PipeOptions.CurrentUserOnly определяется по SID пользователя (и уровню повышения прав), а не по ID сеанса, поэтому он спокойно сочетается со второй строкой таблицы выше (пользователь, через сеансы).

Ещё один момент: если для общемашинного случая (третья строка таблицы) используется Mutex с фиксированным именем Global\, нужно также остерегаться захвата имени (сквоттинга). Если пытаться ограничить приложение единственным экземпляром с помощью именованного Mutex, злоумышленник может заранее создать Mutex с тем же именем и помешать запуску приложения.8 Явное указание ACL при создании через MutexSecurity (MutexAcl.Create) защищает от того, что другой пользователь перехватит или незаконно удержит Mutex, если вам удалось создать его первым. Но учтите: это защита от «помехи задним числом», а не от самого «захвата первым» — этого она не предотвращает. ACL применяется только тогда, когда вы сами впервые создаёте Mutex, поэтому если злоумышленник уже создал Mutex с тем же именем раньше вас, ваш вызов (сколько бы ACL вы ни готовили) просто откроет существующий объект, настроенный им, и вам придётся подчиниться его ACL. Само по себе труднопредсказуемое имя имеет определённую ценность, но если нужно полностью отгородиться от злоумышленника с правом локального выполнения кода, не полагайтесь только на занятие имени Mutex — рассмотрите сочетание с другим механизмом взаимного исключения, например файлом блокировки в защищённом для каждого пользователя каталоге.9

И ещё один момент: у CurrentUserOnly есть легко упускаемое ограничение. В Windows подключение разрешается, только если совпадает не только учётная запись пользователя, но и уровень повышения прав (запущен ли процесс от имени администратора).10 Легко предположить, что «раз имя Mutex строится только из SID пользователя, само обнаружение работает независимо от повышения прав», но при использовании Mutex с явным ACL это тоже подвержено влиянию повышения прав. По умолчанию Windows присваивает объектам, созданным процессом с высоким уровнем целостности (запущенным от имени администратора), метку высокого уровня целостности и отклоняет операции записи со стороны процессов с более низким уровнем целостности. Mutex/MutexAcl.Create в .NET внутренне запрашивают, помимо SYNCHRONIZE и MUTEX_MODIFY_STATE, ещё и DELETE/READ_CONTROL/WRITE_DAC/WRITE_OWNER (STANDARD_RIGHTS_REQUIRED), поэтому если запустить первый экземпляр «от имени администратора», а второй — с обычными правами, сам вызов создания/открытия Mutex может выбросить UnauthorizedAccessException. То есть предпосылка из главы 7 — «ACL отклоняет только канал уведомлений, а сама защита от повторного запуска срабатывает успешно» — может нарушиться, и исключение может вылететь ещё раньше, до самого обнаружения. Этот путь тоже стоит трактовать как сигнал «уже работает другой экземпляр, либо уровень повышения прав отличается, и обычными средствами это не определить»: разумно обернуть сам вызов Mutex/MutexAcl.Create в try/catch (UnauthorizedAccessException) и при исключении отказаться от запуска и тихо завершиться (либо, аналогично «уведомление может и не дойти» из главы 7, склониться к безопасному варианту для защиты от повторного запуска). Если нужно уведомление и через границы уровней повышения прав, откажитесь от CurrentUserOnly и переключитесь на конструкцию, явно строящую ACL на основе SID пользователя через PipeSecurity.

6. Вывод существующего экземпляра на передний план — ограничения SetForegroundWindow

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

Windows жёстко ограничивает, какие процессы могут устанавливать окно переднего плана. Согласно официальной документации, пока вызывающий процесс не удовлетворяет одному из следующих условий, SetForegroundWindow фактически не выводит окно на передний план, а лишь заставляет мигать кнопку на панели задач.6

  • Сам вызывающий процесс является текущим процессом переднего плана
  • Вызывающий процесс был запущен процессом переднего плана
  • Вызывающий процесс получил последнее по времени событие ввода
  • В данный момент окна переднего плана не существует
  • Процесс переднего плана или вызывающий процесс находится под отладкой

В сценарии обнаружения повторного запуска существующий экземпляр (первый процесс, работающий в фоне) обычно не удовлетворяет ни одному из этих условий. А вот второй процесс, который пользователь только что запустил двойным щелчком, часто находится в состоянии сразу после получения события ввода и обладает правом устанавливать передний план. Использование этой асимметрии — стандартный приём на практике.

AllowSetForegroundWindow — это API, позволяющий процессу с правом устанавливать передний план передать это право другому процессу.7 Если второй процесс отдаёт своё право через ASFW_ANY (разрешение всем процессам), а затем запрашивает активацию у существующего экземпляра через именованный канал, вызов SetForegroundWindow на стороне существующего экземпляра начинает срабатывать успешно.

[DllImport("user32.dll")]
private static extern bool AllowSetForegroundWindow(int dwProcessId);

private const int ASFW_ANY = -1;

// Предполагаем, что второй процесс (это мы) обладает правом установки переднего плана,
// и безусловно отдаём это право. Это позволяет вызову SetForegroundWindow
// на стороне существующего экземпляра завершиться успешно
AllowSetForegroundWindow(ASFW_ANY);

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

7. Передача аргументов запуска существующему экземпляру — именованные каналы

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

Дизайн прост: второй процесс, который через Mutex определил, что «экземпляр уже запущен», подключается к именованному каналу, который слушает существующий экземпляр, и отправляет аргументы командной строки, сериализованные, например, в JSON. Практические тонкости самого именованного канала — защита от захвата (сквоттинга) имени канала, контроль доступа через CurrentUserOnly и прочее — собраны в разделе про именованные каналы статьи «Как выбрать способ межпроцессного взаимодействия в Windows», обращайтесь туда. Специфичный именно для контекста обнаружения повторного запуска момент — только один:

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

8. Отличия для консольных приложений и служб

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

Со службами Windows дело обстоит ещё иначе. Диспетчер управления службами (SCM) в принципе никогда не запускает одновременно две службы с одинаковым именем, поэтому обнаружение через Mutex, описанное в этой статье, службам в основном не нужно. В конфигурациях вроде «UI-приложение + резидентная служба» дизайн этой статьи применяется только к UI-стороне; то, как думать о повторном запуске и многократном выполнении на стороне службы, — отдельная тема. Вопросы построения самой службы и её проектирования мы оставляем другой статье.

9. Пример реализации — обнаружение и запрос активации через Mutex

Практический минимальный набор, объединяющий содержание глав 2–7. Пример рассчитан на WPF, но подходит почти без изменений и для WinForms — достаточно заменить Application.Current.Dispatcher на Control.Invoke.

using System.IO.Pipes;
using System.Runtime.InteropServices;
using System.Security.AccessControl;
using System.Security.Principal;
using System.Text.Json;

public static class Program
{
    // Внедряем специфичный для продукта GUID, чтобы имя не конфликтовало с другими приложениями.
    // Поскольку нужно ограничиться одним экземпляром «на пользователя, через сеансы»,
    // добавляем к Global\ ещё и SID пользователя, встроенный в имя. Если оставить
    // только Global\ без SID, все пользователи будут делить один и тот же Mutex,
    // и получится «один экземпляр на всю машину» (глава 3)
    private static readonly string MutexName =
        $@"Global\KomuraSoft.MyApp.SingleInstance.{{3F1E2B10-9C2E-4B7E-8B1E-8B4F2D6A5C10}}.{WindowsIdentity.GetCurrent().User}";
    // Область видимости канала уведомлений тоже приводим в соответствие с Mutex (на пользователя).
    // Если оставить фиксированное имя без SID, возможен конфликт/перепутывание,
    // когда другой пользователь поднимет сервер под тем же именем
    // (глава 5; CurrentUserOnly лишь сужает ACL, но не разделяет имена)
    private static readonly string PipeName =
        $"KomuraSoft.MyApp.Activate.{{3F1E2B10-9C2E-4B7E-8B1E-8B4F2D6A5C10}}.{WindowsIdentity.GetCurrent().User}";

    [STAThread]
    private static void Main(string[] args)
    {
        // Явное указание ACL, разрешающего полный доступ только текущему пользователю,
        // защищает от перехвата или помех со стороны других пользователей — если
        // удалось создать объект первыми (нужен NuGet-пакет System.Threading.AccessControl).
        // Но это не защита от самого «захвата первыми» (глава 5)
        var mutexSecurity = new MutexSecurity();
        mutexSecurity.AddAccessRule(new MutexAccessRule(
            WindowsIdentity.GetCurrent().User!, MutexRights.FullControl, AccessControlType.Allow));

        bool createdNew;
        Mutex mutex;
        try
        {
            // Только когда createdNew равен true, этот вызов действительно получил Mutex
            mutex = MutexAcl.Create(
                initiallyOwned: true, name: MutexName, createdNew: out createdNew, mutexSecurity: mutexSecurity);
        }
        catch (UnauthorizedAccessException)
        {
            // Даже для одного и того же пользователя, если уровень повышения прав отличается,
            // сама попытка открыть существующий Mutex может быть отклонена (глава 5).
            // Считаем, что «уже есть другой экземпляр с другим уровнем повышения прав»,
            // отказываемся от уведомления и склоняемся к безопасному варианту (не запускаться)
            return;
        }
        using var _ = mutex;

        if (!createdNew)
        {
            // Уже запущен. Уведомляем существующий экземпляр и завершаемся сами
            NotifyRunningInstanceAsync(args).GetAwaiter().GetResult();
            return;
        }

        try
        {
            // Обязательно удалите StartupUri из App.xaml. Если оставить его,
            // помимо MainWindow, созданного здесь вручную, автоматически создастся
            // и покажется ещё и окно из StartupUri (при этом app.MainWindow будет
            // перезаписан на него), в итоге откроются два окна, и запрос активации
            // рискует прийти не в то, что нужно
            var app = new App();
            app.InitializeComponent();
            var mainWindow = new MainWindow();
            app.MainWindow = mainWindow;
            mainWindow.Show();

            // Запускаем сервер канала только после того, как MainWindow создано и показано.
            // В обратном порядке запрос активации может прийти, когда окна ещё нет,
            // и вызов ActivateMainWindow может завершиться неудачей (исключение будет
            // проглочено catch-all ниже, и уведомление просто исчезнет). Запрос,
            // пришедший в этот промежуток, укладывается в ту же best-effort
            // конструкцию на стороне NotifyRunningInstanceAsync («сервер ещё не запущен
            // → ошибка подключения», глава 7)
            StartActivationServer();

            app.Run();
        }
        finally
        {
            // Явно освобождаем из владеющего потока (этого потока) перед завершением.
            // Если завершиться без освобождения, это станет причиной AbandonedMutexException
            // при следующем запуске (глава 4)
            mutex.ReleaseMutex();
        }
    }

    private const int ASFW_ANY = -1;

    [DllImport("user32.dll")]
    private static extern bool AllowSetForegroundWindow(int dwProcessId);

    private static async Task NotifyRunningInstanceAsync(string[] args)
    {
        try
        {
            using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
            using var pipe = new NamedPipeClientStream(
                ".", PipeName, PipeDirection.Out, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly);
            await pipe.ConnectAsync(cts.Token);

            // Мы (только что запущенный второй процесс) недавно получили событие ввода
            // и часто обладаем правом установки переднего плана. Безусловно отдаём
            // это право, чтобы SetForegroundWindow на стороне существующего
            // экземпляра завершился успешно (глава 6)
            AllowSetForegroundWindow(ASFW_ANY);

            byte[] payload = JsonSerializer.SerializeToUtf8Bytes(new ActivateRequest(1, args));
            await pipe.WriteAsync(payload, cts.Token);
        }
        catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or OperationCanceledException)
        {
            // Не различаем причину, по которой уведомление не дошло: существующий
            // экземпляр не ответил, находясь в процессе завершения (IOException),
            // либо не прошла авторизация CurrentUserOnly из-за разного уровня
            // повышения прав (UnauthorizedAccessException, см. дополнение к главе 5), и т. п. —
            // основная цель защиты от повторного запуска (не дать запуститься второму) уже достигнута (глава 7)
        }
    }

    private static void StartActivationServer()
    {
        // Верхняя граница на одно сообщение. Защищает память сервера даже если
        // устаревший помощник того же пользователя или сломанный клиент будет
        // бесконечно продолжать отправку
        const int MaxPayloadBytes = 64 * 1024;

        _ = Task.Run(async () =>
        {
            while (true)
            {
                try
                {
                    // Помещаем сам конструктор внутрь try. Поскольку maxNumberOfServerInstances
                    // равен 1, здесь может выбрасываться IOException, например, в момент,
                    // когда ещё не завершена очистка после предыдущего соединения; если
                    // вынести конструктор за пределы try, единственный такой сбой остановит
                    // всю фоновую задачу целиком
                    using var pipe = new NamedPipeServerStream(
                        PipeName, PipeDirection.In, 1,
                        PipeTransmissionMode.Byte, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly);

                    await pipe.WaitForConnectionAsync();

                    // Поскольку maxNumberOfServerInstances равен 1, если это соединение
                    // зависнет на неотвечающем клиенте, все последующие легитимные запросы
                    // запуска перестанут приниматься. Задаём предел времени на одно соединение
                    using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
                    using var ms = new MemoryStream();
                    var buffer = new byte[4096];
                    int n;
                    while ((n = await pipe.ReadAsync(buffer, cts.Token)) > 0)
                    {
                        ms.Write(buffer, 0, n);
                        if (ms.Length > MaxPayloadBytes)
                            throw new IOException("Размер полезной нагрузки превысил допустимый предел.");
                    }

                    var req = JsonSerializer.Deserialize<ActivateRequest>(ms.ToArray());
                    // Проверяем не только Version, но и Args. Если отправитель старой версии
                    // или самодельная некорректная нагрузка пришлёт JSON вида {"Version":1}
                    // без Args, при десериализации Args останется null
                    if (req is { Version: 1, Args: not null })
                    {
                        // Операции с UI выполняем, вернувшись в поток UI
                        Application.Current.Dispatcher.Invoke(() => ActivateMainWindow(req.Args));
                    }
                }
                catch (Exception)
                {
                    // Независимо от причины — обрыв соединения, превышение предела времени
                    // или размера, повреждённая нагрузка, неожиданное исключение во время
                    // диспетчеризации и т. п. — проглатываем это как сбой одного конкретного
                    // соединения. Если дать умереть самой этой задаче, все последующие
                    // уведомления перестанут приниматься, поэтому цикл обязан продолжаться.
                    // При этом, чтобы избежать активного простоя (hot spin) в случае, когда
                    // само построение канала раз за разом мгновенно проваливается ещё до
                    // await (например, слот единственного экземпляра занят другим процессом),
                    // перед каждой следующей итерацией цикла обязательно делаем паузу
                    await Task.Delay(TimeSpan.FromSeconds(1));
                }
            }
        });
    }

    [DllImport("user32.dll")]
    private static extern bool SetForegroundWindow(IntPtr hWnd);

    private static void ActivateMainWindow(string[] args)
    {
        var window = Application.Current.MainWindow;
        if (window is null) return;

        if (window.WindowState == System.Windows.WindowState.Minimized)
            window.WindowState = System.Windows.WindowState.Normal;
        window.Show();
        window.Activate();

        // Activate() в WPF внутри себя вызывает SetForegroundWindow, но из-за
        // ограничения (глава 6) он может не сработать, поэтому вызываем его ещё
        // и явно, уже после AllowSetForegroundWindow
        var hwnd = new System.Windows.Interop.WindowInteropHelper(window).Handle;
        SetForegroundWindow(hwnd);

        if (args.Length > 0)
        {
            // Например, трактуем args[0] как путь к файлу, который нужно открыть, — специфичная для приложения обработка
        }
    }

    private sealed record ActivateRequest(int Version, string[] Args);
}

Дополнительно поясним три проектных решения.

  • В качестве идентификатора пользователя, добавляемого к Global\, выбирайте то, что не меняется со временем. SecurityIdentifier, который возвращает WindowsIdentity.GetCurrent().User, в отличие от имени пользователя, не зависит от переименований, а ToString() даёт строку вида S-1-5-21-....11 Если встроить имя пользователя напрямую, это может привести к аварии — защита от повторного запуска перестанет работать после переименования учётной записи или миграции домена.
  • Освобождение Mutex и остановку сервера канала выполняйте явно, как часть завершения работы приложения. В примере выше ReleaseMutex вызывается в finally, но в реальном приложении нужно также останавливать цикл сервера канала с помощью токена отмены в рамках обработки закрытия окна.
  • Поле version закладывайте с самого начала. Формат аргументов запуска вполне может измениться в будущем. Если с самого начала встроить проверку «неизвестные версии игнорируются», приложение будет вести себя безопасно даже на машинах, где остался старый исполняемый файл.

10. Заключение

Защита Windows-приложения от повторного запуска выглядит просто, если смотреть только на несколько строк new Mutex(true, name, out createdNew). Но чтобы это работало на практике без сбоев, нужно держать в голове весь сопутствующий контекст: разницу в видимости сеансов между Global\ и Local\, специфичное для Mutex ограничение владения потоком и обработку AbandonedMutexException, а также проектирование активации с учётом ограничений SetForegroundWindow.

Что касается порядка реализации: сначала стоит решить, «в каком масштабе (сеанс, пользователь, машина) нужно предотвращать повторный запуск» (глава 5), и в соответствии с этим выровнять пространство имён Mutex и область действия канала уведомлений. Дальше стоит использовать стандартный приём — делегирование права через AllowSetForegroundWindow для вывода существующего экземпляра на передний план, а для случаев, где это всё равно не срабатывает, заранее принять компромисс — ограничиться уведомлением, не форсируя вывод на передний план. С таким настроем реализация не развалится. Если сомневаетесь, что нужно — «один экземпляр на пользователя» или «один экземпляр на всю машину», — рекомендуем сначала уточнить условия эксплуатации (используется ли RDP параллельно с консолью, входят ли несколько пользователей одновременно).

Похожие статьи

Похожие направления консультаций

KomuraSoft LLC (合同会社小村ソフト) занимается проектированием и реализацией десктопных приложений Windows, включая защиту от повторного запуска и управление окнами, поиском причин проблем, характерных именно для сред удалённого рабочего стола, и ревью архитектуры существующих приложений.

Справочные ссылки

  1. Microsoft Learn, Mutex Constructor. О том, что если именованный Mutex уже существует, createdNew становится false без исключения, и что начальное владение через initiallyOwned действует только когда createdNew равен true.  2 3

  2. Microsoft Learn, Mutex Class. О том, что именованный Mutex без указанного префикса по умолчанию использует Local\, о разнице видимости между сеансами служб терминалов через Global\/Local\, и о том, что Mutex принудительно привязывает владение к потоку (в отличие от других объектов синхронизации).  2 3

  3. Microsoft Learn, Kernel Object Namespaces. О структуре отдельных пространств имён для каждого сеанса и глобального пространства имён, а также о способе указания пространства имён через префиксы Global\/Local.  2

  4. Microsoft Learn, Mutex.ReleaseMutex Method. О том, что вызов ReleaseMutex потоком, не владеющим объектом, приводит к ApplicationException, и что если поток завершается без освобождения Mutex, тот переходит в состояние покинутого.  2

  5. Microsoft Learn, AbandonedMutexException Class. О том, что следующему потоку, получившему покинутый Mutex, выбрасывается AbandonedMutexException, и что это означает: само ожидание завершилось успешно, и вызывающая сторона уже владеет Mutex.  2

  6. Microsoft Learn, SetForegroundWindow function. Об условиях, при которых процессу разрешено устанавливать окно переднего плана, и о том, что при невыполнении условий дело ограничивается мерцанием кнопки на панели задач.  2

  7. Microsoft Learn, AllowSetForegroundWindow function. О том, что процесс, способный устанавливать передний план, может передать это право другому процессу, и что указание ASFW_ANY разрешает это любому процессу.  2

  8. Microsoft Learn, CreateMutexW function (synchapi.h). О том, что при ограничении приложения единственным экземпляром через именованный Mutex злоумышленник может заранее создать Mutex с тем же именем и помешать запуску приложения, а также об альтернативах вроде случайного имени или, для случая «один экземпляр на пользователя», файла блокировки в профиле пользователя. 

  9. Microsoft Learn, Mutexes. О том, что именованные системные Mutex видны во всей ОС и являются глобальными, из-за чего рекомендуется защищать их средствами контроля доступа уже с момента создания, а также о контроле доступа через MutexSecurity. 

  10. Microsoft Learn, PipeOptions Enum. О том, что на Windows CurrentUserOnly проверяет не только учётную запись пользователя, но и уровень повышения прав. 

  11. Microsoft Learn, WindowsIdentity.User Property. О свойстве, возвращающем идентификатор безопасности (SID) пользователя, и о том, что SID однозначно идентифицирует пользователя или группу во всех реализациях Windows NT. 

Недавние статьи с теми же тегами помогут подробнее изучить близкие темы.

Если ваше Windows-приложение приняли за вирус — как реагировать на ложные срабатывания Microsoft Defender и жить с влиянием на производительность

Разбираем правильный порядок действий, если Microsoft Defender ложно определяет ваше Windows-приложение как вредоносное: как устроена сов...

Спящий режим, гибернация, Modern Standby и долго работающие приложения — как проектированием предотвратить «остановилось ночью»

Разбираем, почему долго работающее Windows-приложение оказывается «остановленным к утру», начиная с различий между спящим режимом S3, гиб...

Эти страницы показывают тему статьи в более широком контексте услуг и решений.

Статья напрямую связана со следующими услугами.

Частые вопросы

Вопросы, которые часто возникают при консультациях по теме статьи.

Как предотвратить повторный запуск приложения в C#?
Базовая форма — new Mutex(true, name, out bool createdNew). Даже если Mutex с таким именем уже существует, исключения не возникает — просто createdNew становится false, и по этому значению можно определить, что нужно завершить второй процесс. Имя принято составлять из строки с внедрённым GUID продукта, чтобы избежать конфликтов с приложениями других разработчиков. В ветке, где createdNew равен false, безопаснее вообще не трогать Mutex и сразу завершить обработку.
Почему защита от повторного запуска не работает в среде удалённого рабочего стола?
Потому что без префикса именованный Mutex по умолчанию создаётся в пространстве имён Local\ (только в пределах сеанса). В окружении, где один и тот же пользователь имеет несколько сеансов — через консоль и через RDP, — каждый сеанс получает собственный Mutex, и из второго сеанса приложение спокойно запускается ещё раз. Чтобы свести всё к одному экземпляру независимо от сеанса, обязателен префикс Global\. Однако один лишь Global\ означает единый экземпляр на всю машину для всех пользователей, поэтому для варианта «один экземпляр на пользователя» нужно дополнительно встроить в имя SID пользователя.
Как вывести окно уже запущенного экземпляра на передний план?
Простой вызов SetForegroundWindow в большинстве случаев не сработает из-за ограничений ОС и приведёт лишь к мерцанию кнопки на панели задач. Стандартный приём — передать право устанавливать передний план от второго процесса (только что запущенного пользователем двойным щелчком) существующему экземпляру через AllowSetForegroundWindow(ASFW_ANY), а затем запросить активацию через именованный канал. В случаях, где это всё равно не срабатывает (например, запуск через Планировщик заданий), разумнее не форсировать вывод на передний план, а ограничиться уведомлением.
Как обрабатывать AbandonedMutexException?
Если поток, владевший Mutex, завершается без вызова ReleaseMutex (например, из-за краха процесса), следующему потоку, получившему Mutex, выбрасывается AbandonedMutexException. Это исключение означает, что ожидание само по себе завершилось успешно и вызывающий код уже владеет объектом. Правильно не игнорировать его, а сначала проверить целостность защищаемого состояния и только потом использовать ресурс. На пути штатного завершения стоит явно вызывать ReleaseMutex в блоке finally — это избавит от лишнего AbandonedMutexException при следующем запуске.

Об авторе

Страница с профилем автора статьи.

Го Комура

Представитель KomuraSoft LLC

Специализируется на разработке программного обеспечения для Windows, техническом консалтинге и расследовании сбоев, особенно в проектах с унаследованными системами и трудно воспроизводимыми ошибками.

Публичные ссылки

Вернуться в блог