有时候需要让一个线程终止运行,.NET Framework的Thread类中提供了Abort方法,调用某个线程实例对象的Abort方法,可以让该线程类抛出一个ThreadAbortException从而终止该线程的运行。

但使用Abort方法终止一个线程对象显得比较暴力,而且可能会有很多潜在的问题,尤其是一个线程调用另外一个线程的Thread.Abort方法终止时。好消息是从.NET 5.0开始Thread.Abort方法就已经被弃用,如果调用就会抛出“PlatformNotSupportedException”,

要结束一个线程,最好的办法就是让这个线程的线程函数执行完成之后返回。本文主要介绍简单粗暴杀手线程的可能危害,以及如何优雅地退出线程的方法。

可能的问题


CLR中的线程基本是对Windows线程的简单包装,Windows线程由两部分组成:

  • 线程内核对象,操作系统使用它来管理线程。线程内核对象也是操作系统用来存放线程统计信息的地方。
  • 线程栈,用于维护线程执行时所需的所有函数参数和局部变量。这个线程栈的大小默认为1MB

在Windows上,可以有4种方法来终止Windows线程。

  • 让线程函数返回,这也是强烈推荐的使用方式。
  • 线程通过调用ExitThread函数“杀死”自己。这个函数终止当前线程的运行,操作系统会清理该线程使用的所有操作系统资源(线程内核对象和线程栈),但进程所使用的C/C++类对象的资源不会被销毁。这个函数是Windows函数,如果写C/C++代,绝对不要调用ExitThread函数,而是应该使用C++运行库函数_endthread。如果不是Microsoft的C++编译器,则应该使用编译器供应商们提供的自己的ExitThread替代函数。
  • 同一个进程或另一个进程中的线程调用TerminateThread函数。这个函数是异步的,它只是告诉操作系统需要终止线程,但函数返回时并不保证线程已经终止,如果需要确定线程已经终止运行,需要调用WaitForSingleObject或者类似的函数并传递线程的句柄(类似C#中的调用线程的abort后,调用join)。使用TerminateThread函数,除非拥有此线程的进程终止运行,否则系统不会销毁这个线程的堆栈。因为其它还在运行的线程有可能引用了被”杀死”的那个线程的堆栈上的值,如果销毁了这个推展,就会引起访问违规。另外,动态链接库通常会在线程终止运行时收到通知,但如果线程是使用TerminateThread强行“杀死”的,则DLL不会收到这个通知,结果很可能不能执行正常的清理工作。
  • 终止包含线程的进程。终止进程相当于为进程里面的每个线程都调用TerminateThread方法。这就意味着正确的应用程序清理工作不会执行:C++的析构函数不会被调用,数据不会回写到磁盘。

要终止一个Windows线程,最好让他的线程函数自然返回,而要避免使用后面的三种方法。所以如果程序中有多个线程,在主线程返回直之前,要明确妥善处理好每个线程的终止过程,否则其它正在运行的线程可能在毫无预警的前提下突然“死亡”,暴力的结束一个正在运行的线程就相当于一个人走在大街上,突然非正常的挂掉了。

在C#中调用Abort方法来终止另外一个线程时,当CLR在这个线程上抛出ThreadAbortException的时候,我们不知道这个线程是否已经把该做的事情做完了,也不知道这个线程是否操作了应用程序的状态,另外调用Abort方法也可能会阻止静态构造函数的执行或阻止程序释放托管或者非托管资源。

正确的做法


前面说到了,要终止一个线程,最好让他的线程函数执行完成返回。具体到C#中,就是要避免使用Abort方法,好消息是,从.NET 5.0开始,这个方法就不支持了,如果你硬要调用,它就会抛出“PlatformNotSupportedException”😂,这是一个挺好的改进,让我们多思考如何优雅而不是简单粗暴的让线程结束。

要优雅的结束一个线程,可以有以下一些方法:

协作式取消


协作式取消模型(cooperative cancellation model),简而言之就是使用CancellationTokenSource对象,将该对象的Token只读属性传递给线程函数,在线程函数的执行过程中(通常是循环执行),不断判断这个Token成员的IsCancellationRequested属性是否为true,如果是,则线程函数退出循环,从而结束线程函数的执行,进而结束线程。

CancellationTokenSource部分成员如下:

public class CancellationTokenSource : IDisposable
{
	public CancellationToken Token
	{
		get
		{
			ThrowIfDisposed();
			return new CancellationToken(this);
		}
	}
 
	public CancellationTokenSource()
	{
	}

	public CancellationTokenSource(TimeSpan delay)
	{
		long num = (long)delay.TotalMilliseconds;
		if (num < -1 || num > 4294967294u)
		{
			ThrowHelper.ThrowArgumentOutOfRangeException(ExceptionArgument.delay);
		}
		InitializeWithTimer((uint)num);
	}
 
	public void Cancel()
	{
		Cancel(throwOnFirstException: false);
	}

	public void Cancel(bool throwOnFirstException)
	{
		ThrowIfDisposed();
		NotifyCancellation(throwOnFirstException);
	}

	public void CancelAfter(TimeSpan delay)
	{
		long num = (long)delay.TotalMilliseconds;
		if (num < -1 || num > 4294967294u)
		{
			ThrowHelper.ThrowArgumentOutOfRangeException(ExceptionArgument.delay);
		}
		CancelAfter((uint)num);
	}

	  
	public void Dispose()
	{
		Dispose(disposing: true);
		GC.SuppressFinalize(this);
	}
 
}

CancellationTokenSource的Token成员类型如下,注意这个CancellationToken是一个值类型。

[DebuggerDisplay("IsCancellationRequested = {IsCancellationRequested}")]
public readonly struct CancellationToken
{
	private readonly CancellationTokenSource _source;
	public static CancellationToken None => default(CancellationToken);
	public bool IsCancellationRequested
	{
		get
		{
			if (_source != null)
			{
				return _source.IsCancellationRequested;
			}
			return false;
		}
	}

	public bool CanBeCanceled => _source != null;
 
	public CancellationToken(bool canceled)
		: this(canceled ? CancellationTokenSource.s_canceledSource : null)
	{
	}

	public CancellationTokenRegistration Register(Action callback)
	{
		return Register(callback, useSynchronizationContext: false);
	}
 
}

要结束某个包含了Token只读属性的线程函数,只需要在其它线程中调用CancellationTokenSource的Cancel方法,这样就会把Token的IsCancellationRequested属性置为取消。

void Main()
{
	System.Threading.CancellationTokenSource cts=new CancellationTokenSource();
	ThreadPool.QueueUserWorkItem(o=>Count(cts.Token,1000));
	Console.WriteLine("press <enter> to cancel the operation.");
	Console.ReadLine();
	cts.Cancel();
	Console.ReadKey();
}

void Count(CancellationToken token, int countTo)
{
	for(int count=0;count<countTo;count++)
	{
		if (token.IsCancellationRequested)
		{
			Console.WriteLine("count is cancelled");
			break;
		}
		Console.WriteLine(count);
		Thread.Sleep(2000);
	}
	Console.WriteLine("count is done");
}

在上面的代码中,当不再需要计算结果时,调用cts的Cancel方法之后,Count方法内部的for循环里面的token.IsCancellationRequested就为true,这样就退出了线程函数,进而线程自然退出。

手动让方法跳出循环


有些地方的代码可能没有用到协作式取消,那么就必须要确保线程函数在循环中能够有办法跳出。如果没有办法跳出循环就会产生死循环,那就只能通过Abort方法来结束线程了。我这里总结了除了上述的协作式取消之外的两大类退出线程池函数循环的方法。

正常循环体的退出


通常都会将某个线程包装到一个类里边,这个类在内部会发起一个线程,在这个线程中会进行一些耗时的处理,通常是在一个while循环里,然后一直处理,直到收到停止处理的信号退出。退出即意味着线程函数执行完成,线程退出。这种体面退出的通常做法是定义一个bool型的局部变量比如叫isrunning,然后while循环判断。在循环体内部,还需要一个表示退出的信号,这个时候,可以定义一个ManualResetEvent函数。说起来比较复杂,实现其实比较简单,如下:

public class DoSomethingClass
{
    private bool isrunning;
    private ConcurrentQueue<object> todoqueue;
    private AutoResetEvent newdataAE;
    private ManualResetEvent stopME;

    public DoSomethingClass()
    {
        todoqueue = new ConcurrentQueue<object>();
        stopME = new ManualResetEvent(false);
        newdataAE = new AutoResetEvent(false);
        Thread t = new Thread(DoSomething);
        t.IsBackground = true;
        t.Start();
    }

    private void DoSomething()
    {
        WaitHandle[] w = new WaitHandle[] { stopME, newdataAE };
        while (isrunning)
        {
            int i = WaitHandle.WaitAny(w);
            if (i == 0)
            {
                break;
            }
            else if (i == 1)
            {
                while (todoqueue.TryDequeue(out object t))
                {
                    //Do something for t
                }
            }
        }
    }

    public void EnqueueData(object t)
    {
        todoqueue.Enqueue(t);
        newdataAE.Set();
    }

    public void Stop()
    {
        isrunning = false;
        stopME.Set();
    }

}

上面这个是一个典型的场景,在对象初始化时,创建了一个线程,该线程函数是一个while循环,不断的从一个队列里面取出数据来处理。当处理完成之后等待。对象提供了另外一个Enqueu方法来往队列里面添加数据,添加完成之后,通知线程函数有新的数据到来,于是继续执行。

当我们不在需要这个对象的时候,要退出线程函数,就只需要将变量置为false,使得while循环不会再次执行。如果此时循环体内还在等待新数据的到来,那么接下来发起停止信号stopME.Set,就能告诉循环体跳出循环,从而结束线程。

以上这是一种很普遍的场景,使用这种方式可以非常优雅的退出线程。

阻塞式方法的退出


上面介绍的使用局部bool变量加上信号量退出循环的做法可能在某些线程函数内包含有阻塞类函数不太实用。要想退出循环,必须让这些阻塞类的函数抛出异常,或者继续执行。这里对这两种场景分别举了一个例子说明。

第一个例子来自MSDN上的TcpListener,经过适当修改,将它包装在一个类中:

class MyTcpListener
{
    private TcpListener server = null;
    public MyTcpListener()
    {
        Thread tcpServerThread = new Thread(TCPServerProcess);
        tcpServerThread.IsBackground = true;
        tcpServerThread.Start();
    }

    private void TCPServerProcess()
    {
        try
        {
            int port = 13000;
            IPAddress localAddress = IPAddress.Parse("127.0.0.1");
            server = new TcpListener(localAddress, port);
            server.Start();
            byte[] bytes = new byte[256];
            string data = null;
            while (true)
            {
                Console.WriteLine("waiting for a connection");
                using (TcpClient client = server.AcceptTcpClient())
                {
                    Console.WriteLine("connected,from " + client.ToString());
                    data = null;
                    NetworkStream strem = client.GetStream();
                    int i = 0;
                    while ((i = strem.Read(bytes, 0, bytes.Length)) != 0)
                    {
                        data = System.Text.Encoding.ASCII.GetString(bytes, 0, i);
                        Console.WriteLine("received:{0}", data);
                        data = data.ToUpper();
                        byte[] msg = System.Text.Encoding.ASCII.GetBytes(data);
                        strem.Write(msg, 0, msg.Length);
                        Console.WriteLine("send:{0}", data);
                    }
                }
            }
        }
        catch (SocketException e)
        {
            Console.WriteLine("socket exception:{0}", e);
        }
        catch (Exception ex)
        {
            Console.WriteLine(" exception:{0}", ex);
        }
        finally
        {
            server.Stop();
        }
        Console.WriteLine("\nHit enter to continue...");
        Console.Read();
    }

    public void Stop()
    {
        server.Stop();
    }
}

可以看到MyTcpListenter在构造函数中,新建了一个后台线程,在线程中的try中通过while循环不断监听来自客户端的TCP连接,注意这里的AcceptTCPClient方法是一个阻塞的方法,如果没有新的客户端连接,则这个方法一直会阻塞在那里。

所以这种情况下,如果要手动的结束这个进程,需要让这个阻塞方法能够跳出while循环。这里的做法是对外提供一个Stop方法,在方法里调用TcpListenter对象的Stop方法,这样AcceptTCPClient方法就会抛出一个SocketException,从而跳出while循环,继而完成线程函数的执行,从而能够退出进程。

第二个例子来自使用命名管道进行进程间通讯这篇文章,仔细看这个管道通讯的服务端类NamedPipeServer(有删减)。

public class NamedPipeServer
{
    private readonly string _pipeName;
    private readonly PipeSecurity _pipeSecurity;
    public Action<NamedPipeConnection> OnClientConnected;
    public Action<NamedPipeConnection> OnClientDisconnected;
    public Action<NamedPipeConnection, byte[]> OnNewMessage;
    public Action<Exception> OnError;

    private List<NamedPipeConnection> _connections = new List<NamedPipeConnection>();
    private int _nextPipeId;
    private volatile bool _shouldKeepRunning;
    private volatile bool _isRunning;

    public NamedPipeServer(string pipeName, PipeSecurity pipeSecurity = null)
    {
        _pipeName = pipeName;
        _pipeSecurity = pipeSecurity;
    }

    public void Start()
    {
        _shouldKeepRunning = true;
        var worker = new Worker();
        worker.OnError += OnWorkerError;
        worker.DoWork(ListenSync);
    }

    #region Private methods

    private void ListenSync()
    {
        _isRunning = true;
        while (_shouldKeepRunning)
        {
            WaitForConnection(_pipeName, _pipeSecurity);
        }
        _isRunning = false;
    }

    private void WaitForConnection(string pipeName, PipeSecurity pipeSecurity)
    {
        NamedPipeServerStream handshakePipe = null;
        NamedPipeServerStream dataPipe = null;
        NamedPipeConnection connection = null;

        var connectionPipeName = GetNextConnectionPipeName(pipeName);
        byte[] connectionPipeNameBytes = Encoding.UTF8.GetBytes(connectionPipeName);
        try
        {
            // Send the client the name of the data pipe to use
            handshakePipe = CreateNamedPipe(pipeName, pipeSecurity);
            handshakePipe.WaitForConnection();
            var handshakeWrapper = new PipeStreamWrapper(handshakePipe);

            handshakeWrapper.Write(connectionPipeNameBytes);
            handshakeWrapper.WaitForPipeDrain();
            //uint clientProcessID = Helper.GetNamedPipeClientProcID(handshakePipe);
            byte[] clientNameBytes = handshakeWrapper.Read();
            string clientName = Encoding.UTF8.GetString(clientNameBytes);
            handshakeWrapper.Close();

            // Wait for the client to connect to the data pipe
            dataPipe = CreateNamedPipe(connectionPipeName, pipeSecurity);
            dataPipe.WaitForConnection();

            // Add the client's connection to the list of connections
            connection = new NamedPipeConnection(clientName/*Helper.GetClientName((int)clientProcessID)*/, dataPipe);
            connection.OnReceiveMessage += ClientOnReceiveMessage;
            connection.OnDisconnected += ClientOnDisconnected;
            connection.Error += ConnectionOnError;
            connection.Open();

            lock (_connections)
            {
                _connections.Add(connection);
            }

            ClientOnConnected(connection);
        }
        // Catch the IOException that is raised if the pipe is broken or disconnected.
        catch (Exception)
        {
            Cleanup(handshakePipe);
            Cleanup(dataPipe);
            ClientOnDisconnected(connection);
        }
    }

    private void ClientOnConnected(NamedPipeConnection connection)
    {
        if (OnClientConnected != null)
            OnClientConnected(connection);
    }

    #endregion
}

Start方法里也会开启一个线程(这个worker对象是对Task的简单包装),该线程的线程函数是ListenSync,该函数内部是一个while循环,在循环内部调用WaitForConnection方法,这个方法里面下面这句代码:

dataPipe.WaitForConnection();

是一个阻塞的方法,它会一直阻塞,直到有新的客户端连接上来。

要退出这个线程函数,必须要让这个阻塞的函数能够继续执行,NamePipeServerStream及其父方法也没有提供任何类似TcpListener类那样的Stop或Close等方法能够让WaitForConnection停止或者抛出异常。

解决方法就是发起一个模拟的客户端连接请求,让WaitForConnection能够继续执行,从而能够跳出线程函数所在的循环,方法如下:

public void Stop()
{
        _shouldKeepRunning = false;
        lock (_connections)
        {
            foreach (var client in _connections.ToArray())
            {
                client.Close();
            }
        }

        // If background thread is still listening for a client to connect,
        // initiate a dummy connection that will allow the thread to exit.
        //dummy connection will use the local server name.
        var dummyClient = new NamedPipeClient(_pipeName, ".");
        dummyClient.Start();
        dummyClient.WaitForConnection(TimeSpan.FromSeconds(2));
        dummyClient.Stop();
        dummyClient.WaitForDisconnection(TimeSpan.FromSeconds(2));
}

总结


在.NET Framework中调用Thread的Abort方法可以终止一个正在运行的线程,但这种方式过于简单粗暴,且在某些情况下可能会破坏程序的状态,所以在.NET 5.0版本中Abort方法已经被弃用。要结束一个线程,最好的办法就是让线程函数能够执行完成返回。在大部分场景下,线程函数通常是一个while循环,它不停的处理数据。所以要让线程函数能够返回,就是要能够跳出循环体。本文介绍了协作式取消的方法来结束一个线程的方法,这也是微软推荐的做法。同时对于那些没有采用协作式取消的代码,也介绍了两大类比较优雅退出线程的方法,并举了三个例子来说明对应的场景。

让我们拒绝暴力,拥抱优雅😃

 

参考:

https://github.com/dotnet/runtime/blob/main/docs/design/coreclr/botr/threading.md 

https://learn.microsoft.com/en-us/dotnet/api/system.threading.thread.abort?view=net-7.0 

https://learn.microsoft.com/en-us/dotnet/core/compatibility/core-libraries/5.0/thread-abort-obsolete 

https://learn.microsoft.com/en-us/dotnet/api/system.net.sockets.tcpclient?view=net-7.0 

https://learn.microsoft.com/en-us/dotnet/api/system.net.sockets.tcplistener?view=net-7.0 

https://learn.microsoft.com/en-us/dotnet/standard/threading/cancellation-in-managed-threads