В процессе миграции с .NET Framework на .NET Core могут всплыть некоторые неприятные моменты. Например, если ваше приложение использует домены — логику придется переписывать. Аналогичная ситуация с Thread.Abort(): Microsoft настолько не любит эту практику (и справедливо), что сначала они объявили этот метод deprecated, а затем полностью выпилили его из фреймворка и теперь он вероломно выбрасывает PlatformNotSupportedException.

Но что делать, если ваше приложение использует Thread.Abort(), а вы очень хотите перевести его на .NET Core, ничего не переписывая? Ну, мы-то прекрасно знаем, что платформа очень даже поддерживает этот функционал, так что могу вас обрадовать: выход есть, нужно всего лишь собрать свою собственную версию CLR.

Disclaimer: Это сугубо практическая статья с минимумом теории, призванная только продемонстрировать новые варианты взаимодействия разработчика и .NET среды. Никогда не делайте так в продакшене. Но если очень хочется...



Сделать это стало возможным благодаря двум вещам: стремлению Microsoft к кроссплатформенности .NET Core и проделанной разработчиками работе по переносу исходников фреймворка в открытый доступ. Давайте используем это в своих интересах.

Теоретический минимум:

  • dotnet publish возволяет нам публиковать standalone приложение: фреймворк будет поставляться вместе с ним, а не искаться где-то в GAC
  • Версию CoreCLR, на которой будет запускаться приложение, при некоторых условиях можно задать при помощи runtimeconfig.json
  • Мы можем собрать свой собственный CoreFX: github.com/dotnet/corefx
  • Мы можем собрать свой собственный CoreCLR: github.com/dotnet/coreclr

Кастомизируем CoreFX


Прежде чем переходить к нашей основной цели — возвращению Thread.Abort() — давайте для разминки поменяем что-нибудь фундаментальное в CoreFX чтобы проверить работоспособность всех инструментов. Например, по следам моей предыдущей статьи про dynamic, полностью запретим его использование в приложении. Зачем? Потому что мы можем.

Prerequisites


Прежде всего установим все необходимое для сборки:

  • CMake
  • Visual Studio 2019 Preview
  • Latest .NET Core SDK (.NET Core 3.0 Preview)

Visual Studio 2019 — Workloads


.NET desktop development

  • All Required Components
  • .NET Framework 4.7.2 Development Tools

Desktop development with C++

  • All Required Components
  • VC++ 2019 v142 Toolset (x86, x64)
  • Windows 8.1 SDK and UCRT SDK
  • VC++ 2017 v141 Toolset (x86, x64)

.NET Core cross-platform development

  • All Required Components

Visual Studio 2019 — Individual components


  • C# and Visual Basic Roslyn Compilers
  • Static Analysis Tools
  • .NET Portable Library Targeting Pack
  • Windows 10 SDK or Windows 8.1 SDK
  • Visual Studio C++ Core Features
  • VC++ 2019 v142 Toolset (x86, x64)
  • VC++ 2017 v141 Toolset (x86, x64)
  • MSBuild
  • .NET Framework 4.7.2 Targeting Pack
  • Windows Universal CRT SDK

Клонируем corefx:

git clone https://github.com/dotnet/corefx.git

Теперь запретим dynamic. Для этого откроем (здесь и далее я буду указывать относительные от корня репозитория пути)

corefx\src\System.Linq.Expressions\src\System\Runtime\CompilerServices\CallSite.cs

И в конец функции CallSite<T>.Create вставляем незамысловатый код:

throw new PlatformNotSupportedException("No way");

Возвращаемся в corefx и выполняем build.cmd. После окончания сборки, создаем в Visual Studio новый .NET Core проект со следующим содержимым:

public int Test { get; set; }
public static void Main(string[] args)
{
	try
	{
		dynamic a = new Program();
		a.Test = 120;
	}
	catch (Exception e)
	{
		Console.WriteLine(e);
	}
	
	//Узнаем, откуда берутся сборки
	foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
	{
		Console.WriteLine(asm.Location);
	}
}

Компилируем наш проект. Теперь идем в

corefx\artifacts\packages\Debug\NonShipping

и находим там пакет выглядящий примерно так: Microsoft.Private.CoreFx.NETCoreApp.5.0.0-dev.19465.1.nupkg. Открываем .csproj нашего проекта и вставляем туда следующие строки:

<PropertyGroup>
	<PackageConflictPreferredPackages>Microsoft.Private.CoreFx.NETCoreApp;runtime.$(RuntimeIdentifiers).Microsoft.Private.CoreFx.NETCoreApp;$(PackageConflictPreferredPackages)</PackageConflictPreferredPackages>
</PropertyGroup>

<ItemGroup>
	<PackageReference Include="Microsoft.Private.CoreFx.NETCoreApp" Version="5.0.0-dev.19465.1" />
</ItemGroup>

Версия должна быть такая же, как в названии собранного пакета. В моем случае 5.0.0-dev.19465.1.



Переходим в настройки nuget для нашего проекта и добавляем туда два новых пути:

corefx\artifacts\packages\Debug\NonShipping
corefx\artifacts\packages\Debug\Shipping

И снимаем галочки у всех остальных.



Переходим в папку с проектом и выполняем

dotnet publish --runtime win-x64 --self-contained

Готово! Осталось только запустить:



Работает! Библиотеки берутся не из GAC, dynamic не работает.

Make CoreCLR Great Again


Теперь перейдем ко второй части, возвращению Thread.Abort(). Здесь нас ждет неприятный сюрприз: имплементация Thread лежит в CoreCLR, который не является частью CoreFX и предустанавливается на машину отдельно. Сперва создадим демонстрационный проект:

var runtimeInformation = RuntimeInformation.FrameworkDescription;
Console.WriteLine(runtimeInformation);
var thr = new Thread(() =>
{
	try
	{
		while (true)
		{
			Console.WriteLine(".");
			Thread.Sleep(500);
		}
	}
	catch (ThreadAbortException)
	{
		Console.WriteLine("Thread aborted!");
	}
});

foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
{
	Console.WriteLine(asm.Location);
}

thr.Start();
Thread.Sleep(2000);
thr.Abort();

Выкачиваем coreclr. Находим файл

coreclr\src\System.Private.CoreLib\shared\System\Threading\Thread.cs

И заменяем Abort() на

[SecuritySafeCritical]
[SecurityPermissionAttribute(SecurityAction.Demand, ControlThread = true)]
public void Abort()
{
   AbortInternal();
}

[System.Security.SecurityCritical]  // auto-generated
[ResourceExposure(ResourceScope.None)]
[MethodImplAttribute(MethodImplOptions.InternalCall)]
private extern void AbortInternal();

Теперь нам нужно вернуть атрибуты и с++ имплементацию. Я собрал её по кусочкам из различных открытых репозиториев .NET Framework и для удобства оформил все изменения в виде пулл-реквеста.

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


После интеграции этих изменений в код, запускаем build.cmd из coreclr. Сборка на поздних этапах может начать сыпать ошибками, но это не страшно, нам главное чтобы смог собраться CoreCLR. Он будут лежать в:

coreclr\bin\Product\Windows_NT.x64.Debug

Простой путь


Выполняем

dotnet publish --runtime win-x64 --self-contained

Файлы из Windows_NT.x64.Debug скидываем в папку с опубликованным приложением.

Сложный путь


Как только библиотеки собрались, переходим в

C:\Program Files\dotnet\shared\Microsoft.NETCore.App

и клонируем папку 3.0.0. Назовем её, например, 5.0.1. Скопируем туда все, что лежит в Windows_NT.x64.Debug, кроме папок. Теперь наша версия CoreCLR будет доступна через runtimeconfig.

Собираем наш проект. Добавляем в .csproj:

<PropertyGroup>
	<DisableImplicitFrameworkReferences>true</DisableImplicitFrameworkReferences>    	<PackageConflictPreferredPackages>Microsoft.Private.CoreFx.NETCoreApp;runtime.$(RuntimeIdentifiers).Microsoft.Private.CoreFx.NETCoreApp;$(PackageConflictPreferredPackages)</PackageConflictPreferredPackages>
</PropertyGroup>

<ItemGroup>
	<PackageReference Include="Microsoft.Private.CoreFx.NETCoreApp" Version="5.0.0-dev.19465.1" />
</ItemGroup>

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

dotnet publish --runtime win-x64 --self-contained

В runtimeconfig.json впишем следующую конфигурацию:

{
  "runtimeOptions": {
    "tfm": "netcoreapp3.0",
    "framework": {
      "name": "Microsoft.NETCore.App",
      "version": "5.0.1"
    }
  }
}

Результат


Запускаем!



Магия произошла. Теперь в наших .NET Core приложениях снова работает Thread.Abort(). Но, разумеется, только на Windows.

Комментарии (12)


  1. AxisPod
    17.09.2019 09:39

    А чего это MS не любит эту практику, прерывать выполнение потока, а если он в файл в это время пишет или формирует ответ сервера клиенту? Все не любят эту практику, а за использование надо руки отрывать и более не подпускать к компьютеру. Данная функция просто убивает контроль над завершением потока. Ну не надо изначально так делать и при портировании пусть переписывают код на нормальные модели прерывания работы потоков.


    1. iluxa1810
      19.09.2019 16:15

      Имхо, все таки иногда можно грохнуть поток, если есть полная уверенность, что завершение ничему не навредит.

      Например, есть метод из сторонней либы, который на вход принимает массив и выполняет расчеты. Доступа к исходникам нету. Мы запускаем в отдельном потоке выполнение и может так случится, что она не отработает за необходимое время. Что нам делать, так как? Получается, что единственный вариант — это прекратить работу убив поток. Или есть варианты по лучше?


      1. AxisPod
        19.09.2019 16:17

        И какова уверенность, что он не навредит? Утечки памяти и ресурсов это не вред? Кто знает, может там неуправляемый код есть.


        1. iluxa1810
          20.09.2019 00:22

          А есть еще какие-то варианты, если исходный код либы не доступен => туда нельзя добавить проверку токенов отмены?

          Вроде, отдельный домен решает эту проблему красиво и его не жалко прибить?


          1. Lelushak Автор
            20.09.2019 08:09

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

            В текущих условиях мне приходит в голову только вынос кода в отдельный процесс. Это относительно несложно делать на лету при помощи ExpressionTrees и пайпов.


            1. iluxa1810
              20.09.2019 08:14

              А отдельный домен- это разве не полностью изолированная область => пофиг что там поломается при убийстве потоков?

              Правда, утечки неуправляемых ресурсов все же возможны…


              1. Lelushak Автор
                20.09.2019 08:22

                Отредактировал комментарий уже после вашего ответа :(

                Вот что пишут:

                Unloading the AppDomain will release any managed memory associated with it. This is, in fact, how ASP.NET works. The one catch is that unmanaged objects must be set up properly (the full Dispose pattern, for example), otherwise they'll keep it until the process is terminated.


                1. iluxa1810
                  20.09.2019 10:38

                  А есть пример, как на лету создать процесс через ExpressionTrees?


                  1. Lelushak Автор
                    20.09.2019 11:27

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

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


  1. sidristij
    17.09.2019 10:33

    Я видел, как его дёргают через рефлексию ) Дичь, конечно, но проще ))


    1. Lelushak Автор
      17.09.2019 12:39

      Это только в первых версиях .NET Core было возможно, начиная как минимум с 2.1 Thread.AbortInternal вырвали с корнями


      1. SergeyA
        18.09.2019 16:05

        туда ему и дорога!