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

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

История изменений (1 обновлений, последнее 30 Aug 2026)

Журнал изменений этой статьи. Там, где версия до правки была заархивирована, она остаётся доступной для чтения по постоянной ссылке с DOI.

Русский текст переписан как полноценный технический перевод, а не калька с японского. Утверждения статьи не менялись.
Первая публикация
Цитирование статьи(DOI (зарегистрированный архив): 10.5281/zenodo.21619960)

Приведённые ниже DOI относятся к ранее зарегистрированным архивным версиям, которые могут отличаться от текущего текста. Для ссылки на текущий текст используйте URL этой страницы.

Го Комура (2026). Защита Windows-приложения от повторного запуска — именованный Mutex и активация окна при втором старте. KomuraSoft LLC. https://comcomponent.com/ru/blog/single-instance-mutex-guide/

DOI (зарегистрированный архив)
10.5281/zenodo.21619960
DOI (последняя зарегистрированная версия)
10.5281/zenodo.21619961

«Случайно запустил рабочее приложение ещё раз, правил один и тот же файл в двух окнах — и правки из одного окна пропали». «Резидентная утилита поднялась дважды и обработала одно и то же событие два раза». Повторный запуск десктопного приложения — тема неброская, но на практике из-за неё неожиданно часто случаются инциденты. Каркас защиты прост: именованным Mutex (mutual exclusion — взаимное исключение; это объект ядра Windows, «ключ», который в один момент может держать только один поток) проверяют, первый ли это экземпляр.

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

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

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

На схеме сплошная линия обозначает отношение, которое выполняется всегда, а пунктирная — условное отношение (условия указаны в пояснении к каждому отношению на странице сведений). Полный список отношений (всего 19, с доказательствами и степенью уверенности) и определения основных понятий собраны на странице сведений карты знаний (на японском). Данные: JSON-LD / Turtle

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

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

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

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

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

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

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

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

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

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

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

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

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

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

5.1 Как три масштаба соотносятся между собой

От того, как составлено имя, зависит, сколько получится объектов Mutex. Это число и есть верхняя граница одновременно запущенных экземпляров. Возьмём машину, на которую пользователь A вошёл в двух сеансах (консоль и RDP), а пользователь B — в одном. Тогда картина такая.

Машина — только GlobalОдин Mutex на ПКПользователь A, сеанс 1Пользователь A, сеанс 2Пользователь B, сеанс 3Пользователь — Global и SIDMutex пользователя AПользователь A, сеанс 1Пользователь A, сеанс 2Пользователь B, сеанс 3Mutex пользователя BСеанс — без префиксаMutex 1Пользователь A, сеанс 1Пользователь A, сеанс 2Mutex 2Пользователь B, сеанс 3Mutex 3

Где стрелки сходятся в одну рамку — там экземпляры сводят к одному. В этом примере: масштаб сеанса даёт всего 3 экземпляра, масштаб пользователя — 2, масштаб машины — 1. Какую строку выбрать, зависит от требования: «один человек не должен открыть приложение дважды» или «на этом компьютере должен работать только один экземпляр».

5.2 Таблица решений

Масштаб Предполагаемый сценарий Пространство имён Mutex Передача аргументов запуска Замечания
Сеанс (по умолчанию) Обычный рабочий стол без RDP либо достаточно «одного экземпляра на сеанс» Без префикса (то есть Local\) CurrentUserOnly плюс идентификатор сеанса в имени канала В среде с 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

5.3 Масштаб канала уведомлений нужно выровнять с Mutex

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

5.4 Захват имени (сквоттинг) и пределы ACL

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

5.5 Ловушка уровня повышения прав — CurrentUserOnly и уровень целостности

У 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 Второй процесс отдаёт своё право существующему экземпляру, затем запрашивает активацию по именованному каналу — и SetForegroundWindow на стороне существующего экземпляра начинает срабатывать.

Сюда нельзя передавать ASFW_ANY (-1). Справка определяет: если параметр равен ASFW_ANY, все процессы получают право ставить окно переднего плана.7 Получатель у вас один, а разрешение раздаётся всем. Оно действует «пока пользователь не введёт что-то следующее или пока какой-нибудь процесс снова не вызовет AllowSetForegroundWindow»,7 поэтому в тот самый момент, когда пользователь запускает приложение, открывается окно, в котором посторонний резидентный процесс может перехватить фокус. Ради вывода своего окна вы на время снимаете защиту со всей системы.

Нужный получатель — идентификатор процесса существующего экземпляра — берётся из уже подключённого канала. Если передать клиентский дескриптор канала в GetNamedPipeServerProcessId, вернётся PID серверной стороны.11 Это «тот, к кому вы сейчас подключены»: искать его по имени или по окну не нужно.

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

[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool GetNamedPipeServerProcessId(
    SafePipeHandle pipe, out uint serverProcessId);

// PID получателя берём из уже подключённого канала и передаём право только ему.
// Если PID получить не удалось, вывод на передний план пропускаем
// (уведомление всё равно уйдёт — главная цель уже достигнута)
if (GetNamedPipeServerProcessId(pipe.SafePipeHandle, out uint serverProcessId))
{
    AllowSetForegroundWindow(serverProcessId);
}

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

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

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

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

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

8. Чем консольные приложения и службы отличаются

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

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

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

9.1 Что нужно

Перед кодом зафиксируем предпосылки.

Пункт Содержание
Целевой фреймворк Windows TFM на .NET 8 и новее (net8.0-windows и т. п.). Для WPF в файле проекта нужно <UseWPF>true</UseWPF>
Пакет NuGet System.Threading.AccessControl. MutexAcl.Create и MutexSecurity лежат в сборке System.Threading.AccessControl.dll этого пакета и в ссылки по умолчанию не входят12
Используемые пространства имён System.IO.Pipes / System.Runtime.InteropServices / System.Security.AccessControl / System.Security.Principal / System.Text.Json
Целевая ОС Только Windows. Пространство имён Global\, AllowSetForegroundWindow и CurrentUserOnly — механизмы именно Windows
Для WinForms Почти без изменений: Application.Current.Dispatcher.Invoke меняют на Control.Invoke, Application.Current.MainWindow — на ссылку на нужную форму

Пакет добавляют командой dotnet add package System.Threading.AccessControl. Если ACL не задают и обходятся голым new Mutex(...) (первая строка таблицы в п. 5.2, масштаб сеанса), этот пакет не нужен.

9.2 Каркас — обнаружение, уведомление, запуск сервера

Код длинный, поэтому сначала только поток целиком. По сути здесь четыре шага. Тела NotifyRunningInstanceAsync, ShowMainWindow и StartActivationServer — в п. 9.3.

// args — аргументы командной строки Main
string mutexName = $@"Global\KomuraSoft.MyApp.SingleInstance.{WindowsIdentity.GetCurrent().User}";
var security = new MutexSecurity();
security.AddAccessRule(new MutexAccessRule(
    WindowsIdentity.GetCurrent().User!, MutexRights.FullControl, AccessControlType.Allow));

// 1. Обнаружение — по createdNew решаем, мы ли первый экземпляр
var mutex = MutexAcl.Create(
    initiallyOwned: true, name: mutexName, createdNew: out bool createdNew, mutexSecurity: security);

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

// 3. Запуск — если мы первые, показываем окно и только потом поднимаем сервер канала
ShowMainWindow();
StartActivationServer();

// 4. Завершение — освобождаем из того же потока, который захватывал, и выходим
mutex.ReleaseMutex();

Порядок, который нельзя менять, один — в шаге 3: сначала показать окно, потом запускать сервер канала. Если наоборот, сервер получит запрос активации, когда окна ещё нет, и уведомление тихо пропадёт.

9.3 Полная версия

Ниже тот же каркас, в который внесены все оговорки глав 2–7. Пример рассчитан на WPF, но для WinForms достаточно замен из таблицы в п. 9.1.

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

public static class Program
{
    // GUID продукта в имени, чтобы не столкнуться с чужим приложением.
    // Нужен один экземпляр «на пользователя, через сеансы», поэтому к Global\
    // в имя добавляем SID пользователя. Без SID один Global\ разделят все
    // пользователи, и получится «один экземпляр на всю машину» (глава 3, п. 5.1)
    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.4)
        var mutexSecurity = new MutexSecurity();
        mutexSecurity.AddAccessRule(new MutexAccessRule(
            WindowsIdentity.GetCurrent().User!, MutexRights.FullControl, AccessControlType.Allow));

        bool createdNew;
        Mutex mutex;
        try
        {
            // Mutex реально получен этим вызовом, только если createdNew равен true
            mutex = MutexAcl.Create(
                initiallyOwned: true, name: MutexName, createdNew: out createdNew, mutexSecurity: mutexSecurity);
        }
        catch (UnauthorizedAccessException)
        {
            // Даже у одного пользователя при другом уровне повышения прав
            // открытие существующего Mutex могут отклонить (п. 5.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 ниже, уведомление просто исчезнет).
            // Запрос, пришедший в этот промежуток, на стороне
            // NotifyRunningInstanceAsync укладывается в ту же best-effort
            // схему («сервер ещё не запущен → ошибка подключения», глава 7)
            StartActivationServer();

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

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

    // Из подключённого канала берём PID сервера (= существующего экземпляра)
    [DllImport("kernel32.dll", SetLastError = true)]
    private static extern bool GetNamedPipeServerProcessId(
        SafePipeHandle pipe, out uint serverProcessId);

    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).
            // ASFW_ANY(-1) сделал бы получателем «все процессы»
            if (GetNamedPipeServerProcessId(pipe.SafePipeHandle, out uint serverProcessId))
            {
                AllowSetForegroundWindow(serverProcessId);
            }

            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.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-....13 Если встроить имя пользователя напрямую, защита от повторного запуска может перестать работать после переименования учётной записи или миграции домена.
  • Освобождение Mutex и остановку сервера канала делают явно, как часть завершения приложения. В примере выше ReleaseMutex вызывается в finally, но в реальном приложении цикл сервера канала тоже останавливают токеном отмены в обработке закрытия окна.
  • Поле version закладывают с самого начала. Формат аргументов запуска вполне может измениться. Если сразу встроить проверку «неизвестные версии игнорируются», приложение будет вести себя безопасно даже на машинах, где остался старый исполняемый файл.

10. Как проверить, что это работает

Защита от повторного запуска — функция, про которую легко не заметить, что она не работает. После реализации обязательно пройдите шаги ниже руками. Пункты 1–3 обязательны; начиная с 4 — по масштабу, выбранному в таблице п. 5.2.

  1. Двойной запуск в одном сеансе — приложение уже запущено, ещё раз щёлкаете исполняемый файл. Успех: второе окно не открылось, существующее вышло на передний план. В диспетчере задач на вкладке «Подробности» у целевого файла должна остаться одна строка.
  2. Возврат из свёрнутого состояния — первый экземпляр свёрнут, повторяете шаг 1. Если окно так и осталось свёрнутым, не вызвалась обработка WindowState в ActivateMainWindow из п. 9.3.
  3. Передача аргументов запуска — из командной строки запускаете второй экземпляр с аргументом вроде MyApp.exe C:\temp\sample.txt и проверяете, что существующий экземпляр обработал этот путь. Если нет — подозревают несовпадение имени канала или порядок запуска сервера канала (п. 9.2).
  4. Проверка через сеансы (если выбрали масштаб пользователя или машины) — тем же пользователем открываете сеанс консоли и сеанс Remote Desktop и запускаете из обоих. Текущий список сеансов и их ID смотрят командой qwinsta.14 Без префикса здесь спокойно поднимутся два экземпляра (глава 3). Именно в таком виде чаще всего и сообщают о поломке защиты от повторного запуска.
  5. Проверка через пользователей (если выбрали масштаб машины) — открываете ещё один сеанс другим пользователем и проверяете, что пока первый пользователь держит приложение, второй запустить не может. Результат зависит от того, есть ли SID в Global\, — сверяйтесь со схемой в п. 5.1.
  6. Разный уровень повышения прав — первый экземпляр оставляете с обычными правами, второй запускаете «от имени администратора». Может сработать путь UnauthorizedAccessException из п. 5.5; тогда проверяют, что второй экземпляр тихо завершается (не падает с диалогом ошибки).
  7. Покинутый Mutex — первый экземпляр принудительно завершаете через «Снять задачу» в диспетчере задач и запускаете снова. Нормально, если приложение стартует как обычно. Если не стартует, где-то ещё живёт поток или процесс, который держит Mutex. Если тот же Mutex закрывает и другой участок взаимного исключения, параллельно проверяют обработку AbandonedMutexException (глава 4).

Если нужно своими глазами увидеть, под каким именем живёт созданный Mutex, откройте пространство имён диспетчера объектов в WinObj из Sysinternals и ищите по имени.15 Ошибка вида «думали, что поставили Global\, а не поставили» так находится сразу.

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

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

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

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

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

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

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

  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. О том, что процесс, способный устанавливать окно переднего плана, может передать это право процессу, указанному в dwProcessId; что «если этот параметр равен ASFW_ANY, право ставить окно переднего плана получают все процессы»; и что переданное право теряется, «когда пользователь в следующий раз вводит данные (кроме случая, когда ввод направлен этому процессу), либо когда какой-либо процесс в следующий раз вызывает AllowSetForegroundWindow (кроме случая, когда указан тот же процесс, что и в предыдущий раз)».  2 3 4

  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, GetNamedPipeServerProcessId function. О том, что для указанного именованного канала можно получить идентификатор процесса на стороне сервера (начиная с Windows Vista). 

  12. Microsoft Learn, MutexAcl.Create Method. О том, что MutexAcl.Create поставляется в пространстве имён System.Threading, сборке System.Threading.AccessControl.dll и пакете NuGet System.Threading.AccessControl; что пространство имён задают префиксами Global\/Local\, а по умолчанию это Local\; и что по умолчанию именованный Mutex могут открыть и не только создатель, поэтому для ограничения доступа нужно передать MutexSecurity

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

  14. Microsoft Learn, qwinsta. О команде, которая показывает список сеансов на узле сеансов удалённых рабочих столов (имя сеанса, ID, состояние). 

  15. Microsoft Learn, WinObj - Sysinternals. О том, что WinObj показывает пространство имён диспетчера объектов NT и позволяет просматривать именованные объекты ядра. 

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

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

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

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

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

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

Об авторе

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

Го Комура

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

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

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

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