多執行緒並行程式設計的基本問題

2020-10-05 16:00:13

這是個老掉牙的話題,但基本上絕大多數的討論都跑偏了。

絕大多數討論的核心在於 如何設計一把鎖來同步共用變數的存取。 這事實上完全是本末倒置:

  • 我們需要設計的一個立交橋,而不是為了設計一個紅綠燈!

事實上,多執行緒程式設計就不應該存取共用變數,如果真的要在多執行緒存取共用變數,唯一高效的方案就是 嚴格控制時序。 嗯,先來後到是唯一的方法。至於說設計這樣那樣的鎖,那完全是惰政,只是為了防止出問題而已。

早在100多年前,就可以在同一根電話線上傳輸不同的話路,這得益於嚴格的時隙分配和複用機制,後來時代進步了,事情反而變得糟糕了,這完全是由於另一種時隙複用方式引起的,那便是時隙統計複用。現代作業系統和現代分組交換網是這種複用方式的忠實踐行者。

我並不認為統計複用是一種高效的方式,它也許只是面對多樣化場景而不得不採用的一種方案。在我看來,若單單討論高效,沒有什麼比嚴格的時隙複用更好的了。

我來舉一個例子。4個執行緒存取共用變數。

首先看一個稍微嚴格的方案,嚴格分配存取順序:

#include <stdio.h>
#include <pthread.h>
#include <semaphore.h>

sem_t sem1;
sem_t sem2;
sem_t sem3;
sem_t sem4;

unsigned long cnt = 0;
#define TARGET	0xffffff

void do_work()
{
	int i;
	for(i = 0; i < TARGET; i++) {
		cnt ++;
	}
}

void worker_thread1(void)
{
	sem_wait(&sem1);
	do_work();
	sem_post(&sem2);
}

void worker_thread2(void)
{
	sem_wait(&sem2);
	do_work();
	sem_post(&sem3);
}

void worker_thread3(void)
{
	sem_wait(&sem3);
	do_work();
	sem_post(&sem4);
}

void worker_thread4(void)
{
	sem_wait(&sem4);
	do_work();
	printf("%lx\n", cnt);
	exit(0);
}

int main()
{
    pthread_t id1 ,id2, id3, id4;

    sem_init(&sem1, 0, 0);
    sem_init(&sem2, 0, 0);
    sem_init(&sem3, 0, 0);
    sem_init(&sem4, 0, 0);

	pthread_create(&id1, NULL, (void *)worker_thread1, NULL);
	pthread_create(&id2, NULL, (void *)worker_thread2, NULL);
    pthread_create(&id3, NULL, (void *)worker_thread3, NULL);
    pthread_create(&id4, NULL, (void *)worker_thread4, NULL);

	sem_post(&sem1);

	getchar();
	return 0;

}

然後我們看看更加普遍的做法,即加鎖的方案:

#include <stdio.h>
#include <pthread.h>

pthread_spinlock_t spinlock;

unsigned long cnt = 0;
#define TARGET	0xffffff

void do_work()
{
	int i;
	for(i = 0; i < TARGET; i++) {
		pthread_spin_lock(&spinlock);
		cnt ++;
		pthread_spin_unlock(&spinlock);
	}
	if (cnt == 4*TARGET) {
		printf("%lx\n", cnt);
		exit(0);
	}
}

void worker_thread1(void)
{
	do_work();
}

void worker_thread2(void)
{
	do_work();
}

void worker_thread3(void)
{
	do_work();
}

void worker_thread4(void)
{
	do_work();
}

int main()
{
    pthread_t id1 ,id2, id3, id4;

	pthread_spin_init(&spinlock, 0);

	pthread_create(&id1, NULL, (void *)worker_thread1, NULL);
	pthread_create(&id2, NULL, (void *)worker_thread2, NULL);
	pthread_create(&id3, NULL, (void *)worker_thread3, NULL);
    pthread_create(&id4, NULL, (void *)worker_thread4, NULL);
    
	getchar();
}

現在比較一下二者的效率差異:

[root@localhost linux]# time ./pv
3fffffc

real	0m0.171s
user	0m0.165s
sys	0m0.005s
[root@localhost linux]# time ./spin
3fffffc

real	0m4.852s
user	0m19.097s
sys	0m0.035s

和直覺相反,也許你會覺得,第一個例子那不就是退化成序列操作了嗎?多處理器的優勢豈不是無法發揮了?第二個才是多執行緒程式設計正確的姿勢啊!

事實上,對於共用變數,無論如何它都是必須要序列存取的。這種程式碼根本就無法多執行緒化。所以,真正的多執行緒程式設計:

  • 一定要消除共用變數。
  • 若非要共用變數,那就一定嚴格控制存取時序,而不是靠搶鎖來控制並行。

現在來看看Linux核心,大量的spinlock並不是真的讓核心多執行緒化了,而純粹是為了 「如果不引入spinlock就會出問題…」

RSS,percpu spinlock似乎是正確的把式,但若要將已經被揉成亂麻的Linux核心徹底抽絲剝繭般的共用變數序列化,似乎並不容易,更何況,中斷是無法控制其時序的,那麼中斷處理執行緒化如何呢?似乎效果也不是很好。

遇到並行效率問題,如果你去設計一把牛逼的鎖,那事實上你是承認了問題的所在但並不想去解決問題,這是一種消極的應對。

鎖,萬惡之源。取消共用變數或者控制時序才是真理。

那麼,差別是什麼?差別只是一套西服。


浙江溫州皮鞋溼,下雨進水不會胖。