>  기사  >  백엔드 개발  >  .NET 프로그래밍 스레드 풀 내부자

.NET 프로그래밍 스레드 풀 내부자

黄舟
黄舟원래의
2017-02-06 14:19:531331검색

이 기사에서는 .NET4.5의 ThreadPool 소스 코드를 분석 및 설명하여 .NET 스레드 풀의 내부 이야기를 공개하고 ThreadPool 설계의 장점과 단점을 요약합니다.


스레드 풀의 역할


스레드 풀은 이름 그대로 스레드 개체 풀입니다. 작업과 TPL 모두 스레드 풀을 사용하므로 스레드 풀의 내부 이야기를 이해하면 더 나은 프로그램을 작성하는 데 도움이 됩니다. 제한된 공간으로 인해 여기서는 다음과 같은 핵심

개념만 설명하겠습니다.

  • 스레드 풀의 크기

  • 스레드 호출 방법 풀에 작업 추가

  • 스레드 풀의 작업 수행 방법

Threadpool은 IOCP 스레드 조작도 지원하지만 여기서는 공부하지 마세요. 작업과 TPL에 대해서는 해당 블로그에서 자세히 설명하겠습니다.

스레드 풀 크기

어떤 종류의 풀이든 크기는 항상 존재하며 ThreadPool도 예외는 아닙니다. ThreadPool은 스레드 풀의 크기를 조정하는 4가지 방법을 제공합니다:

  • SetMaxThreads

  • GetMaxThreads

  • SetMinThreads

  • GetMinThreads

SetMaxThreads는 스레드 풀이 가질 수 있는 최대 스레드 수를 지정하며 GetMaxThreads는 자연스럽게 이 값을 얻습니다. SetMinThreads는 스레드 풀에서 생존하는 스레드의 최소 수를 지정하고 GetMinThreads는 이 값을 얻습니다.


왜 최대수량과 최소수량을 설정해야 하나요? 스레드 풀의 크기는 가상 주소 공간의 크기와 같은 여러 요인에 따라 달라집니다. 예를 들어 컴퓨터에 4g의 메모리가 있고 스레드의 초기 스택 크기가 1m인 경우 스레드에 메모리 오버헤드가 있기 때문에 최대 4g/1m 스레드를 생성할 수 있습니다(운영 체제 자체 및 기타 프로세스 메모리 할당 무시). , 스레드 풀에 스레드가 너무 많고 완전히 사용되지 않는 경우 이는 메모리 낭비이므로 스레드 풀의 최대 수를 제한하는 것이 합리적입니다.


그럼 최소 인원은 몇 명인가요? 스레드 풀은 스레드의 개체 풀입니다. 개체 풀의 가장 큰 용도는 개체를 재사용하는 것입니다. 스레드를 재사용해야 하는 이유는 스레드의 생성과 소멸이 많은 CPU 시간을 차지하기 때문입니다. 따라서 높은 동시성 상태에서 스레드 풀은 스레드를 생성하고 삭제할 필요가 없기 때문에 많은 시간을 절약하여 시스템의 응답성과 처리량을 향상시킵니다. 최소 수를 사용하면 다양한 동시성 시나리오를 처리하기 위해 생존 스레드의 최소 수를 조정할 수 있습니다.


작업 추가를 위해 스레드 풀을 호출하는 방법


스레드 풀은 주로 2가지 호출 방법을 제공합니다. QueueUserWorkItem 및 UnsafeQueueUserWorkItem .


두 메서드의 코드는 속성이 다른 점을 제외하면 기본적으로 동일합니다. QueueUserWorkItem은 부분 신뢰 코드로 호출할 수 있지만 UnsafeQueueUserWorkItem은 완전 신뢰로만 호출할 수 있습니다. 암호.

public static bool QueueUserWorkItem(WaitCallback callBack)
{
StackCrawlMark stackMark = StackCrawlMark.LookForMyCaller;
return ThreadPool.QueueUserWorkItemHelper(callBack, (object) null, ref stackMark, true);
}

QueueUserWorkItemHelper는 먼저 ThreadPool.EnsureVMInitialized()를 호출하여 CLR 가상 머신이 초기화되었는지 확인한 다음(VM은 Java 가상 머신뿐만 아니라 CLR 실행 엔진을 의미하는 일반적인 용어임) ThreadPoolWorkQueue를 인스턴스화합니다. , 마지막으로 ThreadPoolWorkQueue의 Enqueue 메서드를 호출하고 콜백과 true를 전달합니다.

SecurityCritical]
public void Enqueue(IThreadPoolWorkItem callback, bool forceGlobal)
{
ThreadPoolWorkQueueThreadLocals queueThreadLocals = (ThreadPoolWorkQueueThreadLocals) null;
if (!forceGlobal)
queueThreadLocals = ThreadPoolWorkQueueThreadLocals.threadLocals;
if (this.loggingEnabled)
FrameworkEventSource.Log.ThreadPoolEnqueueWorkObject((object) callback);
if (queueThreadLocals != null)
{
queueThreadLocals.workStealingQueue.LocalPush(callback);
}
else
{
ThreadPoolWorkQueue.QueueSegment comparand = this.queueHead;
while (!comparand.TryEnqueue(callback))
{
Interlocked.CompareExchange<ThreadPoolWorkQueue.QueueSegment>(ref comparand.Next, new ThreadPoolWorkQueue.QueueSegment(), (ThreadPoolWorkQueue.QueueSegment) null);
for (; comparand.Next != null; comparand = this.queueHead)
Interlocked.CompareExchange<ThreadPoolWorkQueue.QueueSegment>(ref this.queueHead, comparand.Next, comparand);
}
}
this.EnsureThreadRequested();
}

ThreadPoolWorkQueue에는 주로 2개의 "대기열"(실제로는 배열)이 포함되어 있습니다. 하나는 QueueSegment(글로벌 작업 대기열)이고 다른 하나는 WorkStealingQueue(로컬 작업 대기열)입니다. 둘 사이의 구체적인 차이점은 작업/TPL에서 설명하고 여기서는 설명하지 않습니다.


forceGlobal이 true이므로 comparand.TryEnqueue(callback)이 실행되는데, 즉 QueueSegment.TryEnqueue입니다. 비교는 대기열의 헤드(queueHead)에서 대기열에 넣기를 시작합니다. 실패하면 대기열에 계속 넣습니다. 성공하면 queueHead에 값을 할당합니다.

QueueSegment의 소스 코드를 살펴보겠습니다.

public QueueSegment()
{
this.nodes = new IThreadPoolWorkItem[256];
}

public bool TryEnqueue(IThreadPoolWorkItem node)
{
int upper;
int lower;
this.GetIndexes(out upper, out lower);
while (upper != this.nodes.Length)
{
if (this.CompareExchangeIndexes(ref upper, upper + 1, ref lower, lower))
{
Volatile.Write<IThreadPoolWorkItem>(ref this.nodes[upper], node);
return true;
}
}
return false;
}

이 소위 전역 작업 대기열은 실제로 IThreadPoolWorkItem의 배열이며 256개로 제한됩니다. 왜 그럴까요? IIS 스레드 풀(스레드가 256개만 있음)과 정렬되어 있기 때문인가요? 인터록과 메모리 쓰기 장벽 휘발성.write를 사용하여 노드의 정확성을 보장하면 동기화 잠금에 비해 성능이 크게 향상됩니다.


마지막으로 EnacheThreadRequested를 호출합니다. EnacheThreadRequested는 QCall을 호출하여 CLR에 요청을 보내고 CLR은 ThreadPool을 예약합니다.

스레드 풀의 작업 수행 방법


스레드가 예약된 후 ThreadPoolWorkQueue의 Dispatch 메서드를 통해 콜백이 실행됩니다.

internal static bool Dispatch()
{
ThreadPoolWorkQueue threadPoolWorkQueue = ThreadPoolGlobals.workQueue;
int tickCount = Environment.TickCount;
threadPoolWorkQueue.MarkThreadRequestSatisfied();
threadPoolWorkQueue.loggingEnabled = FrameworkEventSource.Log.IsEnabled(EventLevel.Verbose, (EventKeywords) 18);
bool flag1 = true;
IThreadPoolWorkItem callback = (IThreadPoolWorkItem) null;
try
{
ThreadPoolWorkQueueThreadLocals tl = threadPoolWorkQueue.EnsureCurrentThreadHasQueue();
while ((long) (Environment.TickCount - tickCount) < (long) ThreadPoolGlobals.tpQuantum)
{
try
{
}
finally
{
bool missedSteal = false;
threadPoolWorkQueue.Dequeue(tl, out callback, out missedSteal);
if (callback == null)
flag1 = missedSteal;
else
threadPoolWorkQueue.EnsureThreadRequested();
}
if (callback == null)
return true;
if (threadPoolWorkQueue.loggingEnabled)
FrameworkEventSource.Log.ThreadPoolDequeueWorkObject((object) callback);
if (ThreadPoolGlobals.enableWorkerTracking)
{
bool flag2 = false;
try
{
try
{
}
finally
{
ThreadPool.ReportThreadStatus(true);
flag2 = true;
}
callback.ExecuteWorkItem();
callback = (IThreadPoolWorkItem) null;
}
finally
{
if (flag2)
ThreadPool.ReportThreadStatus(false);
}
}
else
{
callback.ExecuteWorkItem();
callback = (IThreadPoolWorkItem) null;
}
if (!ThreadPool.NotifyWorkItemComplete())
return false;
}
return true;
}
catch (ThreadAbortException ex)
{
if (callback != null)
callback.MarkAborted(ex);
flag1 = false;
}
finally
{
if (flag1)
threadPoolWorkQueue.EnsureThreadRequested();
}
return true;
}

while 문은 실행 시간이 30ms 미만이면 다음 콜백을 계속 실행한다고 결정합니다. 대부분의 머신 스레드 전환에는 약 30ms가 걸리기 때문입니다. 스레드가 30ms 미만 동안만 실행된 다음 인터럽트 스레드가 전환되기를 기다리는 것은 부끄러운 CPU 낭비입니다.


Dequeue는 실행해야 하는 콜백을 찾는 역할을 담당합니다.

public void Dequeue(ThreadPoolWorkQueueThreadLocals tl, out IThreadPoolWorkItem callback, out bool missedSteal)
{
callback = (IThreadPoolWorkItem) null;
missedSteal = false;
ThreadPoolWorkQueue.WorkStealingQueue workStealingQueue1 = tl.workStealingQueue;
workStealingQueue1.LocalPop(out callback);
if (callback == null)
{
for (ThreadPoolWorkQueue.QueueSegment comparand = this.queueTail; !comparand.TryDequeue(out callback) && comparand.Next != null && comparand.IsUsedUp(); 
comparand = this.queueTail)
Interlocked.CompareExchange<ThreadPoolWorkQueue.QueueSegment>(ref this.queueTail, comparand.Next, comparand);
}
if (callback != null)
return;
ThreadPoolWorkQueue.WorkStealingQueue[] current = ThreadPoolWorkQueue.allThreadQueues.Current;
int num = tl.random.Next(current.Length);
for (int length = current.Length; length > 0; --length)
{
ThreadPoolWorkQueue.WorkStealingQueue workStealingQueue2 = Volatile.Read<ThreadPoolWorkQueue.WorkStealingQueue>(ref current[num % current.Length]);
if (workStealingQueue2 != null && workStealingQueue2 != workStealingQueue1 && workStealingQueue2.TrySteal(out callback, ref missedSteal))
break;
++num;
}
}

글로벌 작업 큐에 콜백을 추가했기 때문에 로컬 작업 큐(workStealingQueue.LocalPop(out) 콜백)) 콜백을 찾을 수 없습니다. 로컬 작업 대기열 검색 콜백은 작업에서 설명됩니다. 그런 다음 전역 작업 대기열로 이동하여 검색하고 먼저 전역 작업 대기열의 시작부터 끝까지 검색하므로 전역 작업 quque의 콜백이 FIFO의 실행 순서입니다.

public bool TryDequeue(out IThreadPoolWorkItem node)
{
int upper;
int lower;
this.GetIndexes(out upper, out lower);
while (lower != upper)
{
// ISSUE: explicit reference operation
// ISSUE: variable of a reference type
int& prevUpper = @upper;
// ISSUE: explicit reference operation
int newUpper = ^prevUpper;
// ISSUE: explicit reference operation
// ISSUE: variable of a reference type
int& prevLower = @lower;
// ISSUE: explicit reference operation
int newLower = ^prevLower + 1;
if (this.CompareExchangeIndexes(prevUpper, newUpper, prevLower, newLower))
{
SpinWait spinWait = new SpinWait();
while ((node = Volatile.Read<IThreadPoolWorkItem>(ref this.nodes[lower])) == null)
spinWait.SpinOnce();
this.nodes[lower] = (IThreadPoolWorkItem) null;
return true;
}
}
node = (IThreadPoolWorkItem) null;
return false;
}

使用自旋锁和内存读屏障来避免内核态和用户态的切换,提高了获取callback的性能。如果还是没有callback,那么就从所有的local work queue里随机选取一个,然后在该local work queue里“偷取”一个任务(callback)。


拿到callback后执行callback.ExecuteWorkItem(),通知完成。

总结

ThreadPool提供了方法调整线程池最少活跃的线程来应对不同的并发场景。ThreadPool带有2个work queue,一个golbal一个local。

执行时先从local找任务,接着去global,最后才会去随机选取一个local偷一个任务,其中global是FIFO的执行顺序。

Work queue实际上是数组,使用了大量的自旋锁和内存屏障来提高性能。但是在偷取任务上,是否可以考虑得更多,随机选择一个local太随意。

首先要考虑偷取的队列上必须有可执行任务;其次可以选取一个不在调度中的线程的local work queue,这样降低了自旋锁的可能性,加快了偷取的速度;最后,偷取的时候可以考虑像golang一样偷取别人queue里一半的任务,因为执行完偷到的这一个任务之后,下次该线程再次被调度到还是可能没任务可执行,还得去偷取别人的任务,这样既浪费CPU时间,又让任务在线程上分布不均匀,降低了系统吞吐量!


另外,如果禁用log和ETW trace,可以使ThreadPool的性能更进一步。

以上就是.NET编程之线程池内幕的内容,更多相关内容请关注PHP中文网(www.php.cn)!


성명:
본 글의 내용은 네티즌들의 자발적인 기여로 작성되었으며, 저작권은 원저작자에게 있습니다. 본 사이트는 이에 상응하는 법적 책임을 지지 않습니다. 표절이나 침해가 의심되는 콘텐츠를 발견한 경우 admin@php.cn으로 문의하세요.
이전 기사:C++17의 최종 기능다음 기사:C++17의 최종 기능