スレッドの同期と相互排他 [Linux]

記事ディレクトリ

1.インポート

マルチスレッドの安全性とは、複数のスレッドが共有リソースに同時にアクセスするときに、リソースの正確性と一貫性を保証する機能を指します。マルチスレッドの安全性を考慮しないと、データ損失、エラー、デッドロックなどの問題が発生する可能性があるため、マルチスレッドの安全性は同時プログラミングにおける重要な概念です。

複数の人が同時に一定数のチケットを取得するのは、マルチスレッド プログラミングの良い例です。この例では、一人一人をスレッド、チケットの数を共有リソースとみなして、全スレッドで共有するグローバル変数として設定します。その簡単な実装を次に示します。

#include <iostream>
#include <unistd.h>
#include <pthread.h>

using namespace std;

int tickets = 10000; // 票数

// 线程函数
void* getTickets(void* args)
{
    
    
	(void)args;
	while(1)
	{
    
    
		if(tickets > 0)
		{
    
    
			usleep(1000);
			printf("[%p]线程:%d号\n", pthread_self(), tickets--);
		}
		else break;
	}
	return nullptr;
}
int main()
{
    
    
	pthread_t t1, t2, t3;
	// 多线程抢票
	pthread_create(&t1, nullptr, getTickets, nullptr);
	pthread_create(&t2, nullptr, getTickets, nullptr);
	pthread_create(&t3, nullptr, getTickets, nullptr);

    pthread_join(t1, nullptr);
    pthread_join(t2, nullptr);
    pthread_join(t3, nullptr);
    return 0;
}

出力:
画像-20230412234934509

但是结果却出现了负数。这是因为代码中存在竞态条件。在多线程环境中,当多个线程同时访问共享数据(即全局的tickets变量)时,可能会出现竞态条件。在当一个线程检查tickets > 0时,另一个线程可能会修改tickets的值。这可能导致多个线程同时进入临界区域并执行tickets–操作,从而导致tickets的值变为负数。

实际上,--操作虽然从C/C++代码来看只有1行,但是它对于CPU(及其寄存器)而言,是3条指令,这可以通过汇编代码验证,截图源自https://godbolt.org/

画像-20230413000923694

因此,tickets–(自增和自减)操作并不是原子的,它实际上包括三个步骤:从内存中读取tickets的值到CPU的寄存器中,CPU将其减1,然后将结果写回内存–这些步骤通常是通过寄存器来完成的。

由于这些步骤并不是原子的,在多线程环境中可能会出现竞态条件。为什么呢?(通过下面这段话就能理解原子操作的重要性)。

首先要明确线程的调度是不确定的,线程随时有可能被切换。也就是说,线程在执行--操作的任意3个步骤的任意可能的时机,都可能被切换。

例如,假设线程A和线程B执行之前tickets的值为1,说明它们都已经通过了tickets > 0这一分支。线程还没从内存中读取tickets的值就被调度器给切换了;此时,线程B被调度,但是它没有被打断,而是完整地执行完3个步骤,所以在内存中tickets的值已经被线程B更新为0了。然后在某个时刻线程A再次被调度回来继续执行,线程A从中断的地方继续执行(注意此时已经通过if判断),从内存中读取的tickets值就是被线程B更新后的0了,那么最后tickets的值就是-1。

したがって、-1 という結果は不確実であり、チケットの初期値を比較的小さく設定すると、(スケジューラによっては) 最終的に 0 が得られる場合がありますが、このような結果も誤りです。 real life. チケットの番号として 0 が使用されます。同様に、スレッド A がメモリからチケットの値を読み取ると、スレッド B にすぐに切り替えられて操作が実行されます--。たとえスレッド B によってチケットの値が 0 に更新されたとしても、チケットを収集し続けるための条件は満たされなくなります。ただし、オペレーティング システムは、スレッド A が切り替わるときにそのコンテキスト データを保存します (レジスタにロードされたデータはコンテキストと呼ばれます)。また、スレッド A が切り替わったときも、スレッド A は元のチケット値 1 を参照するため、スレッド A は引き続き使用します。チケット 値は 0 に更新されます。

複数のスレッドが共有リソースに同時にアクセスするため、データの一貫性を確保するには相互排他ロックまたはその他の同期メカニズムが必要です。共有データへのアクセスは、ミューテックスや条件変数などの同期メカニズムを使用して保護できます。この記事では、同期メカニズムのいくつかについて説明します。

補足: Linux でスレッド ライブラリ関数を含む C++ ソース ファイルを g++ コンパイラを使用してコンパイルする場合は、それが C++ の組み込みスレッド ライブラリであっても-lthread、オプションを追加する必要があります。<thread>

理由:

C++ の組み込みスレッド ライブラリは、より高いレベルの抽象化とインターフェイスを提供する pthread パッケージに基づいており、マルチスレッド プログラムの作成がより便利かつ安全になります。C++ の組み込みスレッド ライブラリには、std::thread、std::mutex、std::condition_variable などのいくつかのクラスと関数が含まれており、これらはすべて pthread の関数をカプセル化または拡張します。

Linux プラットフォームで使用した pthread ライブラリは、スレッドを作成および管理するための一連の関数とデータ型を定義するクロスプラットフォームのスレッド標準です。Pthread は POSIX 標準の一部であるため、POSIX をサポートするオペレーティング システムで使用できます。つまり、このコードを Windows 環境でコンパイルして実行すると、C++ の組み込みスレッド ライブラリが Windows の組み込みスレッド ライブラリにリンクされます。

pthread ライブラリ関数を含む C++ ソース ファイルを g++ コンパイラでコンパイルする場合、pthread は C++ 標準ライブラリの一部ではなく独立したライブラリであるため、-lthread オプションを追加する必要があります。したがって、リンク段階で、pthread ライブラリを見つけて実行可能ファイルにリンクするようにコンパイラに指示する必要があります。-lthread オプションは、pthread ライブラリをリンクするオプションを指定するために使用されます。これにより、システム内で libthread.so または libthread.a という名前のファイルが検索され、それが実行可能ファイルにリンクされます。

2. 主要なコンセプト

マルチスレッド プログラミングでは、複数のスレッドが同時に同じグローバル変数にアクセスして変更する状況にどう対処するかが一般的な問題であり、コードが標準化された方法で記述されていない場合、スレッド セーフティの問題が発生しやすくなります。

スレッド セーフの問題を解決するための一般的な方法は、ロック、セマフォ、ミューテックスなどの同期メカニズムを使用することです。同期メカニズムにより、いつでも 1 つのスレッドだけが共有データにアクセスして変更できるようになり、データの不整合やエラーが回避されます。

2.1 同期と非同期

同期メカニズムを理解する前に、スレッド間の同期の概念を明確にする必要がありますが、同期と非同期は相対的なものであり、一緒に理解することができます。授業を例として、Xiao Ming が授業中に出かける用事があると仮定します。

  • 同期: クラス全体が中断され、シャオ・ミンが戻ってくるまでクラスは再開されません。
  • 非同期: それぞれが自分のことに忙しく、互いに独立してクラスに出席し続けます。

同期と非同期は、2 つ以上のイベント間の関係を説明するためによく使用されます。同期とは、2 つ以上のイベントが特定の順序で発生し、1 つのイベントの発生が別のイベントの完了に依存することを意味します。非同期とは、2 つ以上のイベントの間に固定された順序がなく、それらが独立して発生する可能性があることを意味します。

2.2 相互排他と同時実行

ミューテックスは同時実行の逆です。相互排他とは、同じリソースに同時にアクセスできるのは 1 人の訪問者だけであり、他の訪問者はリソースへのアクセスを開始する前に、前の訪問者がリソースへのアクセスを完了するまで待つ必要があることを意味します。同時実行性とは、オペレーティング システム内で複数のプログラムが同じプロセッサ上で同時に実行されることを意味します。

たとえば、映画館で映画が上映されており、この映画の座席がリソースであるとします。

  • 相互排除: 映画が売り切れると、誰もその映画のチケットを購入できなくなります。
  • 同時実行性: 複数の映画が劇場で同時に上映される場合、観客は他の映画のチケットを購入することを選択できます。これが同時実行性の概念です。

ここでは、まず相互排除について理解します。その後の詳細な学習を通じて、並行性を徐々に理解できます。まず第一に、同時実行性をセットの観点から見ることができます。映画館で映画を鑑賞するすべての人は完全なセット C であり、映画のチケットを購入した人はセット A に属し、映画を購入しなかった人はセット C に属します。チケットはセット B に属し、セット A+セット B= 完全なセット c. その場合、この「どちらか」の関係は相互に排他的です (あなたか私のどちらか)。

2.3 アトミック操作

アトミック操作とは、中断できない 1 つまたは一連の操作です。これらの操作は、別のスレッドが操作の実行を開始する前に 1 つのスレッドによってのみ実行できます。つまり、これらの操作は分割できず、スレッドがこれらの操作を交互に実行することはできません。たとえば、例の操作はアトミックではありません--。 3ステップ。

したがって、マルチスレッド プログラミングで上記の例と同様のエラーを減らすには、アトミック操作を使用する必要があります。アトミック操作には完了前と完了後の 2 つの状態しかなく、中間状態 (進行中) はありません。

アセンブリ命令は CPU レジスタ ハードウェアの操作に対応するため、アセンブリの観点から見ると、操作は 1 つのアセンブリ命令にのみ対応し、この操作はアトミックです。CPU の場合、アトミック命令は CPU によって直接実行される操作です。

2.4 重要なリソースと重要なセクション

クリティカル リソースとクリティカル エリアはオペレーティング システムの 2 つの重要な概念であり、プロセスの同期と相互排他に密接に関連しています。

重要なリソース

  • クリティカル リソースとは、マルチプロセス環境で複数のプロセスが同時に使用またはアクセスできないリソースを指します。

たとえば、プリンター、テープドライブ、ファイルなどです。複数のプロセスが重要なリソースを同時に使用またはアクセスすると、データの不整合やエラーが発生する可能性があります。したがって、重要なリソースについては、プロセス間の相互排他的アクセスを実装する必要があります。つまり、常に 1 つのプロセスのみがリソースを使用またはアクセスでき、リソースを使用またはアクセスする必要がある他のプロセスは待機する必要があります。

クリティカルセクション

  • クリティカル セクションは、マルチプロセス環境で重要なリソースにアクセスするコード部分を指します。

クリティカル セクションにはクリティカル リソースの操作が含まれるため、常に 1 つのプロセスだけがクリティカル セクションのコードを実行でき、クリティカル セクションのコードを実行する必要がある他のプロセスは待機する必要があることが保証されなければなりません。クリティカルセクションのコードを複数のプロセスが同時に実行すると、データの不整合やエラーが発生する可能性があります。

管理方法

为了保护临界资源和管理临界区,操作系统提供了一些机制,例如信号量、互斥锁、条件变量、管程等。这些机制的基本思想是:在进入临界区之前,进程必须先获取一个标志或锁,表示该进程拥有对临界资源的访问权;在退出临界区之后,进程必须释放该标志或锁,表示该进程放弃对临界资源的访问权;如果一个进程试图获取一个已经被其他进程占用的标志或锁,那么该进程将被阻塞,直到其他进程释放该标志或锁为止。

通过这些机制,可以实现对临界资源和临界区的有效保护和管理,从而保证多进程环境下的数据一致性和正确性。

3. 互斥锁

3.1 引入

承接上面的抢票程序,判断tickets > 0本质也是计算的一种方式。在CPU计算之前要先将内存中的数据加载(load)CPU的寄存器中,数据从内存流到了寄存器只是体现在数据传递的层面,这是理解结果出错的难点。从执行流的角度看,当前CPU正在执行哪个执行流的指令,它的寄存器中存放的就是哪个执行流的数据。当多个线程访问同一个全局变量(共享资源),可能会导致上下文数据中的这个全局变量的值本来已经到极限了,却线程眼中的却是它被修改之前的值,这是由于线程在混乱的时序下切换造成的结果。

这和(不)可重入函数是类似的,C/C++中对变量进行--操作,在线程切换时时有风险的。而这只是一个独立的示例,实际情况要复杂得多,线程被调度(被切换)也是不确定的。

如何避免这样的问题?

  • 对全局变量(共享资源)进行保护。

它出现负数的原因是--操作被打断了,线程A还没让CPU计算就被切换了,而线程B看到的还是原来的值,当线程B更新以后,全局变量的值就已经不合法了,但是线程A被切换回来,「恢复线程上下文,上下文中全局变量的值是旧的值,通过了if判断」,所以多减了一次。

つまり、根本的な原因は操作が中断されたことです。他のスレッドがこのような非アトミック操作を実行できないようにする--メカニズムがあれば、最終的には共有リソースが正当であることが保証されます。--

このメカニズムはどのように実装されているのでしょうか?

チケットを取得する例では、共有リソースに固有のマークを使用して相互排他メカニズムを実装できます。つまり、すべてのスレッドが同じ共有リソースにアクセスする前に、オペレーティング システムはその 1 つのスレッドにのみマークを付けることを許可します。アクセス。

3.2 コンセプト

相互排他ロック (ミューテックス) は、複数のスレッド間の同期メカニズムを実装するために使用されるツールです。これにより、一度に 1 つのスレッドのみが共有リソースまたはコード セグメントにアクセスできるようになります。ミューテックス ロックは、マルチスレッド プログラムにおけるデータ競合やデッドロックなどの問題を回避し、プログラムの正確性と安定性を向上させることができます。

ミューテックスの基本的な使用法は次のとおりです。

  1. ミューテックス オブジェクトを作成し、ロックの所有権を取得するためにクリティカル領域にアクセスする必要があるコードの前に、ミューテックスの lock() 関数を呼び出します。
  2. クリティカルセクションにアクセスした後、ミューテックスのunlock()関数を呼び出してロックの所有権を解放します。

スレッドがタスクを実行してロックを解放すると、ロックはロックの取得を待機している他のスレッドに渡され、上記の操作を繰り返してタスクを安全に完了します。

補充:

C++ 標準ライブラリは、ミューテックス関数を実装するための std::mutex クラスと、ミューテックスの管理と例外安全性を簡素化するために使用される 2 つの補助クラス std::lock_guard および std::unique_lock を提供します。– ただし、この記事では、C++ のミューテックス ロックについては紹介しませんが、pthread ライブラリのロックを例として取り上げます。前述のように、C++ の組み込みスレッド ライブラリ関数も pthread ライブラリを通じて実装されます。関数。

pthread ライブラリでは、ミューテックスの作成、初期化、ロック、ロック解除、および破棄を行うためのミューテックス関連の関数が提供されています。ミューテックス ロックは、グローバル ロックとローカル ロックに分類できます。使用方法は異なりますが、必ず使用する必要があります。初期化、ロック、ロック解除操作する。

  • グローバル ロックは、プログラムのグローバル変数領域で定義されたミューテックスを指し、プログラム内のどのスレッドでも使用できます。グローバル ロックの利点は、使いやすく、パラメータを渡す必要がなく、メモリを動的に割り当てる必要がないことです。グローバル ロックの欠点は、異なるスレッドが異なる共有リソースにアクセスする必要がある場合があるにもかかわらず、同じミューテックスしか使用できないため、リソースの無駄が発生する可能性があることです。これにより、不必要な待機とブロックが発生します。さらに、グローバル ロックはデータのカプセル化を破壊するため、モジュール式プログラミングには適していません。

  • ローカル ロックとは、プログラムのローカル変数領域またはヒープ領域に定義されたミューテックスを指し、それが定義されている関数または構造体のスレッドによってのみ使用できます。ローカル ロックの利点は、必要に応じて複数のミューテックスを作成でき、各ミューテックスは 1 つの共有リソースのみを保護するため、同時実行性と効率が向上することです。さらに、ローカル ロックはデータのカプセル化を維持するため、モジュール式プログラミングにも有益です。ローカル ロックの欠点は、パラメータを渡すか、メモリを動的に割り当てる必要があるため、プログラミングの複雑さとオーバーヘッドが増加することです。

ロックとロック解除とは何ですか?

ロックとロック解除は、重要なセクションで相互排他を実現する方法です。ロックとは、クリティカル セクションに入る前に、スレッドがロック オブジェクトを取得する必要があることを意味します。ロック オブジェクトがすでに他のスレッドによって占有されている場合、ロック オブジェクトが解放されるまで待機またはブロックする必要があります。ロック解除とは、クリティカル セクションを終了した後、スレッドがロック オブジェクトを解放する必要があることを意味します。これにより、待機中の他のスレッドがロック オブジェクトを取得してクリティカル セクションに入る機会が与えられます。– 最も重要な点は、ロックを取得していないスレッドがタスクの実行に割り当てられている場合、そのスレッドはブロックされて待機します。

3.3 例

pthread_mutex 関数ファミリー

pthread_mutex関数ファミリーは、ミューテックスを操作するための POSIX スレッド ライブラリ内の関数のセットです。それらには次のものが含まれます。

  • pthread_mutex_init: ミューテックスを初期化します。これは 2 つのパラメータを受け入れます。最初のパラメータはpthread_mutex_t型、2 番目のパラメータはpthread_mutexattr_tミューテックスのプロパティを設定するために使用される型の変数へのポインタです。デフォルトのプロパティを使用する場合は、2 番目のパラメータを に設定できますNULL
  • pthread_mutex_destroy: ミューテックスを破棄します。pthread_mutex_tの変数へのポインタをパラメータとして受け取ります。ミューテックスを使用した後、この関数を呼び出してリソースを解放する必要があります。
  • pthread_mutex_lock: ミューテックスをロックします。pthread_mutex_tの変数へのポインタをパラメータとして受け取ります。ミューテックスがすでにロックされている場合、この関数を呼び出すスレッドは、ミューテックスがロック解除されるまでブロックされます。
  • pthread_mutex_trylock: ミューテックスのロックを試みます。pthread_mutex_tの変数へのポインタをパラメータとして受け取ります。ミューテックスがすでにロックされている場合、関数はブロックせずにすぐに戻ります。
  • pthread_mutex_unlock: ミューテックスのロックを解除します。pthread_mutex_tの変数へのポインタをパラメータとして受け取ります。共有リソースを使用した後、この関数を呼び出してミューテックスのロックを解除し、他のスレッドが共有リソースにアクセスできるようにする必要があります。

上記は、pthread_mutex関数。これらはすべてpthread_mutex_t、型変数へのポインターをパラメーターとして受け入れ、成功すると 0 を返し、失敗するとエラー コードを返します。

使用法

pthread のミューテックス () はpthread_mutex_t構造体タイプで、ミューテックスのステータスと属性を表すいくつかの内部変数が含まれています。当面は、これらの変数の具体的な意味を気にする必要はありません。スレッド間の相互排他を実現するために使用されることだけを理解する必要があります。次のように進めます。

pthread_mutex_t 型の変数を使用するには、まず初期化する必要があります。初期化には 2 つの方法があります。

  • 静的初期化: コンパイル時にミューテックスに定数値を割り当て、それがデフォルト属性を持つミューテックスであることを示します (当面はデフォルト属性が何であるかを気にする必要はありません)。このメソッドは次の場合にのみ使用できます。グローバル変数または静的変数。例えば:

    pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER // 它是一个宏
    

    これにより、デフォルトの属性を持つミューテックス変数 mutex が作成されます。静的初期化の利点は、単純で便利であり、関数を呼び出す必要がないことですが、欠点は、デフォルト属性のみが使用でき、再帰的かどうか、再帰的であるかどうかなど、他の属性を指定できないことです。丈夫であるなど。

  • 動的初期化: 関数を呼び出して実行時に変数を初期化します。このメソッドはグローバル変数、静的変数、およびローカル変数に使用できます。例えば:

    pthread_mutex_t mutex;
    pthread_mutex_init(&mutex, NULL);
    

    これにより、デフォルトの属性を持つミューテックス変数 mutex も作成されます。


    注: 属性については、当面は気にしないでください。通常は nullptr/NULL に設定されます。

    ただし、静的初期化とは異なり、動的初期化では 2 番目のパラメーターを pthread_mutexattr_t 型の変数として指定でき、これを使用してミューテックスの属性を設定できます。次に例を示します。

    pthread_mutex_t mutex;
    pthread_mutexattr_t attr;
    pthread_mutexattr_init(&attr);
    pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_RECURSIVE);
    pthread_mutex_init(&mutex, &attr);
    

これにより、再帰的な属性ミューテックス変数 mutex が作成されます。再帰的プロパティは、同じスレッドがデッドロックを引き起こすことなく同じミューテックスを複数回ロックできることを意味します。動的初期化の利点は、ミューテックスの属性を柔軟に設定できることですが、欠点は、複数の関数を呼び出す必要があり、ミューテックスと属性変数のメモリの解放に注意する必要があることです。次に例を示します。

pthread_mutex_destroy(&mutex);
pthread_mutexattr_destroy(&attr);

つまり、pthread_mutex_t 型の変数は重要なスレッド同期メカニズムであり、共有リソースが複数のスレッドによって同時に変更されないように保護するために使用できます。さまざまなニーズに応じて、静的初期化または動的初期化を選択してミューテックス変数を作成し、それらの正しい使用と解放に注意を払うことができます。

グローバルロック

  1. ;を使用してpthread_mutex_tグローバル ロックを定義します。
  2. グローバル変数を操作する前にロックを使用しますpthread_mutex_lock()
  3. グローバル変数を操作した後は、ロック解除を使用してくださいpthread_mutex_unlock()
#include <iostream>
#include <unistd.h>
#include <pthread.h>

using namespace std;

pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER; // 定义一个全局锁
int tickets = 10000; // 票数

// 线程函数
void* getTickets(void* args)
{
    
    
	(void)args;
	while(1)
	{
    
    
		pthread_mutex_lock(&mtx); // 加锁
		if(tickets > 0)
		{
    
    
			usleep(1000);
			printf("线程[%p]:%d号\n", pthread_self(), tickets--);
			pthread_mutex_unlock(&mtx); // 解锁
		}
		else
		{
    
    
			pthread_mutex_unlock(&mtx); // 解锁
			break;
		} 
	}
	return nullptr;
}
int main()
{
    
    
	pthread_t t1, t2, t3;
	// 多线程抢票
	pthread_create(&t1, nullptr, getTickets, nullptr);
	pthread_create(&t2, nullptr, getTickets, nullptr);
	pthread_create(&t3, nullptr, getTickets, nullptr);

    pthread_join(t1, nullptr);
    pthread_join(t2, nullptr);
    pthread_join(t3, nullptr);
    return 0;
}

出力

画像-20230413155442629

グローバル変数チケットの最終値が0や1にならず、スレッド数も3、チケット数も10,000となっており、ミューテックスを使用すると時間が長くなっていることが分かります。スクリーンショットはすべて同じスレッドですが、実行プロセス中に他のスレッドが実行されていることも確認できます。特定のスレッドが頻繁に表示される理由としては、このスレッドの優先度が比較的高く、スケジューラによって最初にスケジュールされることが考えられます。– これはスケジューラの動作であり、ユーザーが作成したコードではありません。

ロックする前は、スレッドのスケジュールが不確実であるため、各スレッドの重要なリソースへのアクセスは互いに独立していますが、ロック後は 1 つのスレッドのみが重要なリソースへのアクセスを許可されるため、最終的にはグローバル変数が正当であることが保証されます。同時に、ある程度のパフォーマンスの低下をもたらします。

ローカルロック

ローカルに定義されたロックの場合、ロックを初期化するために対応する初期化関数を呼び出す必要があり、対応する場所でロックも破棄する必要があります。

int main()
{
    
    
	pthread_mutex_t mtx; // 定义局部锁
	pthread_mutex_init(&mtx, NULL); // 初始化锁

	pthread_t t1, t2, t3;
	// 多线程抢票
	pthread_create(&t1, nullptr, getTickets, nullptr);
	pthread_create(&t2, nullptr, getTickets, nullptr);
	pthread_create(&t3, nullptr, getTickets, nullptr);

    pthread_join(t1, nullptr);
    pthread_join(t2, nullptr);
    pthread_join(t3, nullptr);

	pthread_mutex_destory(&mtx); // 销毁锁
    return 0;
}

ただし、この場合、スレッド関数はgetTickets()メイン関数で定義されたローカルロックを参照できませんが、パラメータを渡すことでそれを実現できます。スレッド関数のパラメータは void* 型であるため、任意の型の実パラメータを受け取ることができます。配列として、またはオブジェクトの場合でも、パラメーターが (void*) として渡され、関数内で戻されれば、パラメーターの内容を取得できます。データ型の違いはメモリに対する観点の違いにすぎず、メモリ データへのアクセスが制限されますが、データ自体は変わりません。一部のネットワーク ディスクがアップロードが許可されていないリソースを検出するようなものですが、サフィックスを変更してからアップロードできます。後で使用したい場合は、元に戻すことができます。内部のコンテンツは変更されません。

スレッド情報 (スレッド エイリアスなど) とロックのアドレスをオブジェクトにパッケージ化し、スレッド関数に渡すことができます。このオブジェクトの型は次のように定義できますThreadData

#include <iostream>
#include <string>
#include <unistd.h>
#include <pthread.h>
#include <ctime>
#include <chrono>
using namespace std;

#define THREAD_NUM 5

int tickets = 10000; 		// 票数

class ThreadData
{
    
    
public:
	// 构造函数
	ThreadData(const string& tname, pthread_mutex_t* pmtx)
	: _tname(tname)
	, _pmtx(pmtx)
	{
    
    }
public:
	string _tname;			// 线程名
	pthread_mutex_t* _pmtx;	// 锁的地址
};

// 线程函数
void* getTickets(void* args)
{
    
    
	ThreadData* td = (ThreadData*)args; 	// 获取参数传递的数据
	while(1)
	{
    
    
		pthread_mutex_lock(td->_pmtx); 		// 加锁
		if(tickets > 0)
		{
    
    
			usleep(1000);
			printf("线程[%p]:%d号\n", pthread_self(), tickets--);
			pthread_mutex_unlock(td->_pmtx); // 解锁
		}
		else
		{
    
    
			pthread_mutex_unlock(td->_pmtx); // 解锁
			break;
		} 
		usleep(rand() % 1500);				// 抢完票的后续操作, 用sleep代替
	}
	delete td; 								// 销毁数据
	return nullptr;
}
int main()
{
    
    
	auto start = std::chrono::high_resolution_clock::now(); // 计时开始
	pthread_mutex_t mtx; 					// 定义局部锁
	pthread_mutex_init(&mtx, NULL); 		// 初始化锁
	srand((unsigned long)time(nullptr) ^ 0x3f3f3f3f ^ getpid());

	pthread_t t[THREAD_NUM];
	for(int i = 0; i < THREAD_NUM; i++)		// 多线程抢票
	{
    
    
		string tname = "thread["; 			// 线程名
		tname += to_string(i + 1); tname += "]";
		ThreadData* td = new ThreadData(tname, &mtx); 			// 创建保存数据的对象
		pthread_create(t + i, nullptr, getTickets, (void*)td); 	// 创建线程的同时将名字和数据对象传递
	}
	for(int i = 0; i < THREAD_NUM; i++)		// 等待线程
	{
    
    
   		pthread_join(t[i], nullptr);
	}

	pthread_mutex_destroy(&mtx); 			// 销毁锁
	auto end = std::chrono::high_resolution_clock::now();    // 计时结束
	cout << "THREAD_NUM = " << THREAD_NUM << endl;
	cout << "共花费: " << chrono::duration_cast<std::chrono::milliseconds>(end - start).count() << "ms" << endl; 
    return 0;
}

追加されたロジック:

  1. ThreadData クラスのメンバーには、スレッド情報 (便宜上、スレッド名のみが使用されます。実際には、スレッドには他の情報があります)、およびスレッド関数 getTickets() によって使用される main 関数で定義されたローカル ロックのアドレスが含まれます。 ;
  2. スレッド関数 getTickets() 関数では、スレッドはロック、重要なリソースへのアクセス、ロック解除後にデータの処理などの他の作業を行う必要があります。ここでは、代わりに乱数の usleep が使用されます。main の乱数シードは、乱数をよりランダムにすることを目的として、いくつかの数値 (任意に取得) と XOR 演算されます。
  3. ループ内にスレッドを作成し、スレッドの名前と番号をバインドし、ロックのアドレスを ThreadData オブジェクトにパックします。このオブジェクトはnew存在しないため、deleteスレッド関数 getTickets() の最後に必要であることに注意してください。関数内では、スレッド情報とロックを使用するためにオブジェクトを使用してそのメンバー変数を抽出する必要があります。
  4. 性能解析を後から時間に置き換えるために、メイン関数の最初と最後にタイミングロジックを追加し、<chrono>ヘッダーhigh_resolution_clock。ここでの使用について気にする必要はありません。

出力:

画像-20230413174300895

3.4 パフォーマンスの損失

上記の例で、THREAD_NUMを100に変更した場合、実行時間は短縮されますか(スレッド関数内のusleepをコメントアウトし、print文を追加します)。

画像-20230413191624103

結果から、スレッドがスリープ状態にならない場合でも、それほど高速化されるわけではなく、ミリ秒単位です。

スレッドの数を増やすとプログラムの実行時間が短縮される可能性がありますが、これは絶対的なものではありません。プログラムの実行時間は、ハードウェアのパフォーマンス、オペレーティング システムのスケジューリング ポリシー、プログラムの構造、アルゴリズムの複雑さなど、多くの要因によって決まります。マルチコアプロセッサシステムでは、スレッド数を増やすことでマルチコアプロセッサの並列計算能力を最大限に活用することができ、プログラムの実行時間を短縮することができます。ただし、スレッド数が多すぎると、スレッド間のスケジューリングや同期のオーバーヘッドも増加し、プログラムの実行効率に影響を及ぼします(つまり、スレッドのスケジューリングにも時間がかかります)。

また、プログラムに大量のシリアル計算や I/O 操作がある場合、スレッド数を増やしてもプログラムの実行時間が大幅に改善されない可能性があります。

ミューテックスは共有リソースのセキュリティを保護できますが、主に次の側面でパフォーマンスのオーバーヘッドももたらします。

  • ミューテックスの作成と破棄にはオペレーティング システムの API を呼び出す必要があり、一定の時間とメモリ リソースが消費されます。
  • ミューテックスのロックとロック解除にはアトミック操作が必要なので、CPU 命令の数とメモリ アクセス時間が増加します。
  • ミューテックスの待機とウェイクアップにはコンテキストスイッチ(context switch)が必要となり、CPUキャッシュ(cache)の無効化やスレッドスケジューリング(scheduling)の遅延が発生します。
  • ミューテックスの競合により、スレッドのブロック (ブロッキング) またはビジー待機 (ビジー待機) が発生し、スレッドの使用率と同時実行性が低下します。

したがって、ミューテックスは、特にミューテックスによって保護されているコード セグメントやリソースにおいて、マルチスレッド プログラムの効率をある程度低下させます。

  • 非常に頻繁にアクセスされるため、激しいロック競合が発生します。
  • 実行に非常に時間がかかるため、ロックの保持時間が長くなります。
  • 非常に簡単に処理されるため、ロックのオーバーヘッドの割合が高くなります。

では、マルチスレッド プログラムの効率に対するミューテックスの影響を軽減するにはどうすればよいでしょうか? 一般に、いくつかの提案があります。

  • ミューテックスの数と範囲を最小限に抑え、必要な共有データまたは重要なセクションのみを保護し、過剰同期を回避します。
  • ミューテックスの保持時間を短縮し、できるだけ早くロックを解放し、ロックを保持している間は I/O 操作やその他の時間のかかる操作を避けるようにしてください。
  • 読み取り/書き込みロック、スピン ロック、条件変数などのより効率的な同期メカニズムの使用を試み、さまざまなシナリオに応じて適切なツールを選択してください。

つまり、ミューテックスは長所と短所のある同期メカニズムであり、マルチスレッド プログラムの正確性と安定性を確保できますが、プログラムの効率も低下します。したがって、ミューテックスを使用する場合は、長所と短所を比較検討し、最高のパフォーマンスを達成するために合理的にコードを設計および最適化する必要があります。

3.5 シリアル実行

マルチスレッド プログラムでは、複数のスレッドが共有リソースにアクセスする必要がある場合、通常、同期メカニズム (ミューテックスなど) を使用して共有リソースを保護する必要があります。スレッドがロックを取得してクリティカル セクションに入ると、クリティカル セクションに入ろうとする他のスレッドは、ロックが解放されるまでブロックされます。このようにして、クリティカル セクションでは複数のスレッドがシリアルに実行されます (xin, 2)。

シリアル実行は、単一スレッドでのステートメントの実行順序を記述するために使用することも、複数のスレッド間での実行順序を記述するために使用することもできます。プログラムでは、命令が順番に実行されることを意味し、各命令の実行は前の命令が完了した後に実行する必要があります。以下は、シリアル実行を示す簡単な C++ プログラムです。

#include <iostream>
int main() 
{
    std::cout << "Step 1" << std::endl;
    std::cout << "Step 2" << std::endl;
    std::cout << "Step 3" << std::endl;

    return 0;
}

このプログラムでは、3 つのstd::coutステートメントが順番に実行されます。プログラムの出力は次のとおりです。

Step 1
Step 2
Step 3

プログラム内の命令が順番に実行される、つまりシリアル実行であることがわかります。このコードのオブジェクトは各ステートメントですが、シリアル実行のオブジェクトはスレッドにすることもできます。

シリアル実行は、マルチスレッドの安全性を実現する方法であり、複数のスレッドを同時にではなく特定の順序で順番に実行できるようにすることを指します。シリアル実行では、同じリソースに対する複数のスレッドの競合を回避できるため、リソースの整合性と正確性が保証されます。率直に言うと、スレッドをキューに入れてタスクを 1 つずつ実行します。, 効率は当然、複数のスレッドを同時に実行する場合ほど良くありません。

シリアル実行の利点は、シンプルで理解しやすく、追加の同期メカニズムが必要なく、デッドロックなどの問題が発生しないことです。シリアル実行の欠点は、効率が悪く、マルチコア プロセッサのパフォーマンスを最大限に活用できず、真の並列処理を実現できないことです。デッドロック関連の内容は、この記事のセクション VI にあります。

シリアル実行はいくつかの方法で実現できます。

  • 単一スレッドを使用する: 1 つのスレッドだけですべてのタスクを実行する場合、マルチスレッドの安全性、つまりシリアル実行の問題は発生しません。これは最も簡単な方法ですが、最も効率が低い方法でもあります。
  • ミューテックスを使用する: ミューテックスは、常に 1 つのスレッドだけが共有リソースにアクセスできるようにする同期メカニズムです。リソースにアクセスしたい他のスレッドは、ロックが解放されるまで待ってから続行する必要があります。このアプローチでは部分的な並列処理を実現できますが、オーバーヘッドと複雑さも増加します。
  • キューの使用: キューは、先入れ先出し (FIFO) ベースでデータを保存および処理するデータ構造です。共有リソースにアクセスする必要があるすべてのタスクがキューに入れられ、専用スレッドがこれらのタスクをキュー内の順序で順番に実行する場合、シリアル実行を実現できます。このアプローチでは、ロックの使用量は削減されますが、待ち時間とメモリ消費量も増加します。

つまり、マルチスレッドの安全性では、シリアル実行は単純ですが非効率な方法であり、パフォーマンス要件が低く、正確性要件が高いシナリオに適しています。

ロックはシリアル実行ですか?

ロックすると、クリティカル セクション内で複数のスレッドが連続して実行されます。ただし、これはプログラム全体がシリアルに実行されることを意味するわけではありません。クリティカル セクションの外では、複数のスレッドが並行して実行できます。ロックは単なる同期メカニズムであり、プログラムの並列性を変更するものではありません。共有リソースにアクセスするときに複数のスレッドが競合しないようにするだけです。

3.6 補足

ロック後、クリティカルセクションのコード実行時にスレッドが切り替わりますか?

答えは「はい」です。ロック後、スレッドはクリティカル セクションで切り替えられる場合があります。これはオペレーティング システムのスケジューリング メカニズムによって決定されます。ロックは、クリティカル セクションに入る前にスレッドが切り替えられないことを保証することしかできませんが、クリティカル セクションの実行プロセス中に、タイム スライスの使い果たし、中断の発生、そして CPU は積極的に待機を放棄します。スレッドが切り替えられると、スレッドはロック オブジェクトを保持したままになり、再度スケジュールされてクリティカル セクション コードが実行されるまでロック オブジェクトを解放しません。

上記のコードを再検討すると、ロックを保持しているスレッドが切り替わると、他のスレッドはロックを申請できなくなり、他のすべてのスレッドはクリティカル セクションのコードを実行できなくなり、クリティカル リソースのデータの一貫性が確保されます。(兄は江湖にはいませんが、江湖には私の伝説がまだ残っています~) 実際、セキュリティへの影響はありません。他のスレッドがしばらく待たされるだけで、効率が低下します。[これも非常に悪いです。それを軽減する方法があります]。

では、クリティカルセクションでスレッドが切り替わった場合、何か影響があるのでしょうか? (「スレッドがクリティカル セクションにある」ということは、「スレッドがクリティカル セクションでコードを実行している」ということと同じであることに注意してください)

主な影響は次の 2 つです。

一方で、クリティカル セクションでスレッドが切り替えられると、待機中の他のスレッドが時間内にクリティカル セクションに入ることができなくなります。つまり、スレッドが切り替えられると、スレッドはロックを使用して実行されますが、特定の重要なリソースにはロックが 1 つしかありません。。从而降低了程序的并发性能和响应速度。因此,在设计临界区时,应该尽量减少临界区的长度和复杂度,避免在临界区中进行耗时的操作或者调用可能阻塞的函数。

另一方面,线程在临界区中被切换也可能导致一些逻辑错误或者死锁的情况。例如,如果一个线程在获取了一个锁对象后,在临界区中又试图获取另一个锁对象,而这个锁对象恰好被另一个线程占用,并且这个线程又在等待第一个线程释放的锁对象,那么就会形成一个循环等待的死锁。因此,在设计临界区时,应该遵循一些规范和原则,例如避免嵌套使用多个锁对象、按照固定的顺序获取和释放锁对象、使用超时机制或者死锁检测机制等。实际上,有个重要的规则就是能不用锁就不用锁,因为查错非常麻烦。

如果临界区有很多个语句,会出现问题吗?

虽然临界区的代码有很多,但是互斥锁保证了临界区的代码在同一时间只有一个线程能访问,在代码本身满足要求的情况下,不会有问题。

这取决于临界区的代码是否满足以下几个原则:

  • 原子性:临界区的代码应该是不可分割的,即要么全部执行,要么全部不执行。如果临界区的代码中有可能抛出异常或者被中断,那么就需要使用异常处理或者信号处理机制,确保临界区的代码在任何情况下都能正确地退出,并释放锁。
  • 互斥性:临界区的代码应该只能由一个线程执行,即不能有其他线程同时进入临界区。这需要使用同步机制,如互斥锁、信号量、条件变量等,来保证只有一个线程能够获得对共享资源的访问权。
  • 有序性:临界区的代码应该按照预期的顺序执行,即不能有指令重排或者内存可见性问题。这需要使用内存屏障或者原子操作,来保证临界区的代码在不同的处理器或者内存模型下都能正确地执行。

什么是正确的多线程编码方式?

我们无法控制调度器调度线程的策略,只能人为地通过加锁和解锁限制在同一时间段对共享资源的访问权限,这个操作一定需要程序员手动实现。而共享资源对于在同一个进程地址空间的所有线程而言是裸露的,它们可以直接访问共享资源。加锁只是利用了语句执行的顺序是从上到下的特点,如果在临界区中或后申请锁,那锁也没啥用了。

しかし、スレッドがロックを適用せずに重要なリソースにアクセスするのは、間違ったマルチスレッド プログラミング方法です。具体的な理由は上で何度も説明しました。これを防ぐには、ミューテックスなどの同期メカニズムを使用して共有リソースを保護する必要があります。スレッドが共有リソースにアクセスする必要がある場合、まずロックを適用してから、クリティカル セクションに入る必要があります。共有リソースを使用した後は、他のスレッドが共有リソースにアクセスできるようにロックを解放する必要があります。

重要なリソースにアクセスするには、各スレッドがロックを申請する必要があります。つまり、ロックはすべてのスレッドで共有される必要があるため、ロック自体も共有リソースになります。ロックは重要なリソースのセキュリティを保証しますが、ロック自体のセキュリティは誰が保証するのでしょうか?

ロック自体のセキュリティは、オペレーティング システムのアトミック操作によって保証されます。アトミック操作により、マルチスレッド環境では常に 1 つのスレッドだけがロックにアクセスできることが保証され、ロックの適用と解放の操作もアトミックであるため、ロック自体のセキュリティが確保されます。

4. 相互排他ロックの実装原理

ミューテックスの実現原理はハードウェアレベルとソフトウェアレベルに分けられますが、ここではソフトウェアレベルを例に、CPUとレジスタの動作の一部のみを説明します。

ミューテックスの本質は、メモリ内の数値であるマーキング関数です。

4.1 スレッドの実行とブロック

ロックのないスレッドは、タスクが割り当てられた後、ロックの割り当てを待っている間にハングします。

ソフトウェア レベルでの実装原理は、主にオペレーティング システムによって提供されるスケジューリング メカニズムに依存します。つまり、オペレーティング システムはスレッドまたはプロセスの実行とブロックを制御できます。オペレーティング システムは、ミューテックスの状態と待機キューを維持できます。スレッドまたはプロセスが共有リソースにアクセスする場合、最初にミューテックスの状態をチェックします。ミューテックスが占有されていない場合は、引き続きアクセスしてミューテックスを設定できます。ロックの状態は占有に設定されます。ミューテックスがすでに占有されている場合は、自分自身を待機キューに追加してブロックする必要があります。共有リソースにアクセスした後、ミューテックスの状態を非占有に設定し、待機 A をウェイクアップします。キュー内のスレッドまたはプロセス。この種のミューテックスはスリープ ロック (スリープロック) とも呼ばれます。これは、待機中のスレッドまたはプロセスがスリープしてウェイクアップされるまで待機する必要があるためです。

4.2 スピンロックとミューテックス

コンセプト

Linux カーネルでは、ロックとロック解除はアトミック命令を使用して実装されます。アトミック命令は CPU によって直接実行される操作であり、アトミックであることが保証されています。

互斥锁(mutex)和自旋锁(spinlock)是两种常见的同步机制,用于保护临界区的访问。它们的区别在于,当一个线程试图获取一个已经被占用的锁时,互斥锁会让该线程进入睡眠状态,等待锁的释放;而自旋锁则会让该线程不断地循环检查锁的状态,直到获取到锁为止。因此,互斥锁可以避免浪费CPU资源,但是会增加上下文切换的开销;而自旋锁可以减少上下文切换的开销,但是会占用CPU资源。

在Linux中,我们可以用自旋锁的实现原理从汇编角度理解互斥锁,事实上,Linux内核中的互斥锁就是基于自旋锁实现的。

具体来说,Linux内核中定义了一个结构体mutex,其中包含了一个自旋锁和一个等待队列。自旋锁通过不断检查锁的状态来防止多个线程同时访问共享资源。如果锁被占用,线程会一直等待,直到锁被释放。当一个线程试图获取一个互斥锁时,它首先会尝试获取该互斥锁内部的自旋锁。如果成功,说明该互斥锁没有被占用,那么该线程就可以进入临界区;如果失败,说明该互斥锁已经被占用,那么该线程就会将自己加入到等待队列中,并释放自旋锁,然后进入睡眠状态。当一个线程释放一个互斥锁时,它首先会检查等待队列是否为空。如果为空,说明没有其他线程在等待该互斥锁,那么该线程就可以直接释放自旋锁;如果不为空,说明有其他线程在等待该互斥锁,那么该线程就会从等待队列中取出一个线程,并唤醒它,并将自旋锁转移给它。

从汇编角度来看,Linux内核中使用了一些特殊的指令来实现自旋锁和互斥锁。例如,在x86架构下,Linux内核使用了lock前缀来保证指令的原子性;使用了xchg指令来交换两个操作数的值(本节最重要的指令);使用了cmpxchg指令来比较并交换两个操作数的值;使用了test_and_set_bit指令来测试并设置一个位;使用了test_and_clear_bit指令来测试并清除一个位;使用了pause指令来优化自旋循环等。这些指令都是利用了CPU的硬件支持来实现原子操作和内存屏障

ロックは、共有リソースへの複数のスレッドの相互排他的アクセスを保証するために使用される一般的な同期メカニズムです。ただし、ロックの実装は単純ではなく、xchgb/xchg 命令など、いくつかの低レベルのアトミック操作が必要です。xchgb/xchg 命令は 2 つのオペランドの値を交換する命令であり、実行中に他のスレッドや割り込みによって中断されることのないアトミックな命令です。xchgb 命令を使用すると、スピンロック (スピンロック) と呼ばれる単純なロックを実装できます。

xchgコマンド

x86 アーキテクチャ プロセッサには、2 バイト サイズのメモリ アドレスの値をアトミックに交換できる xchgb (exchange byte) と呼ばれる命令があります。オペランドの 1 つはレジスタでなければならず、もう 1 つのオペランドはレジスタまたはメモリにすることができます。住所。xchgb 命令の実行は中断できません。つまり、その実行中、他のスレッドまたはプロセスはそれに関与するメモリ アドレスにアクセスできません。このようにして、ミューテックス上の操作がアトミックであること、つまり競合状態が発生しないことが保証されます。アトミック性とは、この命令が実行中に他の命令によって中断されず、ハードウェアによってサポートされている他のプロセッサやバスによって中断されないことを意味します。xchgb コマンドの形式は次のとおりです。

xchgb %al, (%ebx)

この命令の意味は、レジスタ al の値をメモリ アドレス ebx の値と交換し、交換された値をそれぞれレジスタ al とメモリ アドレス ebx に格納し直すことです。たとえば、レジ​​スタ al の値が 0x01、メモリアドレス ebx の値が 0x00 の場合、この命令を実行すると、レジスタ al の値は 0x00、メモリアドレス ebx の値は 0x01 になります。

xchgb 命令と xchg 命令は両方とも、2 つのオペランドの値を交換するために使用されます。それらの主な違いはオペランドのサイズです。

xchgb 命令を使用して、ミューテックスをロックおよびロック解除することができます。ミューテックス変数 mutex の場合、初期値 0 のバイトサイズのメモリ アドレスです。mutex が 0 の場合はミューテックスが空いていることを意味し、mutex が 1 の場合はミューテックスが占有されていることを意味します。

スピンロックの追加とロック解除

以下は、スピンロックの単純な x86 アセンブリ バージョンの例です。この例では、xchgbディレクティブを使用してスピンロックを実装します。

spin_lock:
movb $1, %al # 将1放入寄存器al
xchgb %al, (lock) # 交换al和内存中lock变量的值,并将原来的值放入al
testb %al, %al # 测试al是否为0
jnz spin_lock # 如果不为0,说明锁已经被占用,跳回spin_lock继续等待
ret # 如果为0,说明锁已经获得,返回

spin_unlock:
movb $0, (lock) # 将0放入内存中lock变量,释放锁
ret # 返回

このようにして、spin_lock と pin_unlock を使用して、相互排他的アクセスを必要とする重要なセクションや共有リソースを保護できます。例えば:

spin_lock # 调用spin_lock获取锁
# ...临界区代码...
spin_unlock # 调用spin_unlock释放锁

上記の例の各命令の説明は次のとおりです。

  • spin_lock関数はロックの取得を試みます。xchgb命令を使用してロックの値を 1 と交換し、交換された値を確認します値が 0 の場合は、スレッドがロックを正常に取得したことを意味します。それ以外の場合は、待機を続けて再試行します。

    • mov al, 1: 1をalレジスタに移動します。
    • xchgb al, [lock]:alレジスタ内の値をメモリ内のロック値と交換します。
    • test al, al:alレジスタの値が 0 かどうかをテストします。
    • jnz spin_lock:alレジスタの値が 0 でない場合、spin_lockラベル、つまり最初のmov al, 1命令ジャンプします [リトライ]。
    • ret: 関数から返されました。
  • spin_unlockロックを解除するには関数を使用します。他のスレッドがロックを取得できるように、ロックの値を 0 に設定します。

    • mov byte [lock], 0: メモリ内のロック値を 0 に設定します。

    • ret: 関数から返されました。

他のバージョンでは、レジスタの名前は eax である可能性がありますが、それは問題ではありません。

ミューテックスの追加とロック解除

以下は、xchgb 命令を使用してミューテックスのロックおよびロック解除操作を実装するコード スニペットです。これは、xchgbCPU とメモリ間で命令がどのように動作してミューテックスを実装するかを説明しています。lockミューテックスとして使用されるバイト変数があるとします。初期値は 0 で、ロックが占有されていないことを示します。スレッドがロックを取得しようとすると、次の処理が行われます。

; 加锁操作
mov al, 1
lock xchgb [mutex], al
test al, al
jnz try_again

; 解锁操作
mov [mutex], 0

このアセンブリ コードの断片は次のことを意味します。

  1. 1をalレジスタに移動します。

  2. xchgb命令を使用して、alレジスタ内の値をメモリ内のロック値と交換します。

    a. メモリ内のロック値を CPU に読み取ります。

    b.alレジスタの値をメモリのロック位置に書き込みます。

    c. 読み取りロック値をalレジスタに書き込みます。

  3. al交換後のレジスタの値が 0 の場合はロックが成功したことを意味し、それ以外の場合はロックが失敗したことを意味するため、再度ロックを試行する必要がありますロック解除の操作は非常に簡単で、メモリ内のロック値を 0 に設定するだけです。

注:try_againアセンブリ コードのキーワードではありません。これは、コード内の位置をマークするために使用されるラベルです。上記のサンプル コードでは、jnz try_againこの命令は、前の命令の結果がtest al, al0 以外の場合、try_againラベルの位置にジャンプして実行を継続することを意味します。このようにして、周期的にロックを試みる動作を実現することができる。

これらの操作はアトミックです。つまり、プロセス全体を通じて、他のスレッドはメモリ内のロック値にアクセスしたり変更したりすることはできません。したがって、スレッドが交換されたロック値をチェックすると、ロックが正常に取得されたかどうかを判断できます。

ミューテックスアプリケーション

ロックが空いていることを示す初期値 0 のミューテックス変数 lock があるとします。スレッドがこのロックを適用する場合、次のアセンブリ コードを実行できます。

movl $1, %eax  # 将1放入寄存器eax
xchgb %al, lock  # 交换eax的低字节和lock的值,并将结果存入lock
testb %al, %al  # 测试eax的低字节是否为0
jnz busy  # 如果不为0,说明锁已经被占用,跳转到busy标签,线程挂起阻塞
		   # 如果为0,说明锁已经成功申请,继续执行临界区代码		

このコードの機能は、ロックの値が 0 の場合、それを 1 に交換し、ロックが適用されていることを示す 1 をロックに格納し、ロックの値が 1 の場合、それを 1 に交換します。 1、eax の下位バイトに 1 が格納され、ロックがすでに占有されていることを示します。次に、eax の下位バイトが 0 であるかどうかをテストして、ロックが正常に適用されたかどうかを判断します。成功した場合はクリティカル セクションに入ることができ、失敗した場合は待機するか再試行する必要があります。通常、スレッドはロックの適用に失敗するとハングアップしてブロックされます (つまり、スリープ状態になります)。

補充:

  1. $1即値(定数またはオペランド)1と%eaxレジスタeaxを示します。この命令の機能は、即値 1 をレジスタ eax に移動することです。アセンブリ構文が異なると、即値とレジスタに異なるシンボルが使用される場合があります。たとえば、Intel 構文アセンブリ コードでは、通常、即値やレジスタを表すために特殊記号は使用されません。

  2. x86 アーキテクチャでは、eaxレジスタは 32 ビット レジスタであり、alレジスタを通じて下位 8 ビットにアクセスできます。この場合、ロック値が 0 であるかどうかのみが考慮され、ロック値の他のビットは考慮されません。

スレッドスイッチ

ミューテックスの適用中にスレッドが切り替わると、そのコンテキスト (レジスタ値やプログラム カウンタ値を含む) がメモリに保存されます。スレッドが実行を継続するためにスイッチバックされると、そのコンテキストが復元され、スレッドはスイッチアウトされた場所から実行を継続できるようになります。

命令の実行後にスレッドがスイッチアウトされるとxchg、そのコンテキスト (alレジスタ値を含む) がメモリに保存されます。スレッドが実行を継続するために元に戻されると、そのコンテキストが復元され、alレジスタ内の値が復元されます。このようにして、スレッドはtest al, al命令の実行を継続したり、ロック値が 0 かどうかを確認したりすることができます。

各スレッドには独自のレジスタ値のセットがありますが、これらの値は CPU 内に独立して存在するのではなく、コンテキストの切り替えを通じて実装されます。

実行フローの観点から見ると、CPU のレジスタは、さまざまなスレッド コンテキストを保存および切り替えるための「ツール」です。制限されたレジスタはすべての実行フローによって共有されますが、レジスタが指す各スレッドのコンテキストはスレッドにとってプライベートです。したがって、スレッドの観点では、レジスタは現在の実行フローのコンテキストになります (レジスタはコンテキストのアドレスを保持しているため)。

4.3 ミューテックスロックの性質

ミューテックスの本質は、共有リソースに固有の数値であり、スレッドが共有リソースにアクセスできるかどうかを示します。このフラグを持つスレッドのみが共有リソース上で動作できます。アトミック コマンドによりミューテックスの転送が安全になるため、ミューテックスは共有リソース データの一意性も保証できます。

アトミック操作はハードウェア レベルでサポートされています。スレッドの場合、アトミック操作の 2 つの状態は、スレッドにとって最も意味のある 2 つの状況に対応します (スレッド A とスレッド B があると仮定します)。

  1. 何も行われません。スレッド A にはロックがありません。これは、他のスレッドがロックの適用に失敗したことを意味します。その後、スレッド A は単独でロックを適用できます。
  2. 実行してください。スレッド A がロックを解放し、スレッド B がロックを申請できるようになります。

ロックを保持しているスレッドの場合、他のスレッドはロックをめぐって競合できませんが、これはスケジューラによって異なります。ロックを申請しているスレッドの場合、アプリケーションが失敗した場合、それはロックをめぐって他のスレッドと競合していることを意味します。スケジューラ プロセッサはまだロックを取得することを決定していません; どちらの場合も他のスレッドに対してアトミックです。

5. リエントラントかつスレッドセーフ

再入可能とスレッド セーフの区別は、マルチスレッド環境におけるプログラミングに関する一般的な質問です。簡単に言えば、リエントラント関数とは、実行中に関数を中断でき、中断後に元の実行状態に影響を与えることなく再度呼び出すことができることを意味します。スレッドセーフ関数とは、データ競合やロジック エラーを引き起こすことなく、複数のスレッドから同時に関数を呼び出すことができることを意味します。

5.1 リエントラント関数

再入可能な関数の例については、ここをクリックしてください

再入可能とは、関数が複数のタスクまたはスレッドから安全に呼び出されることを意味します。関数の実行中に中断または切り替えられた場合でも、関数の正確性と一貫性には影響しません。リエントラント関数は通常、次の原則に従います。

  • グローバル変数や静的変数は使用せず、ローカル変数または受信パラメーターのみを使用してください。
  • malloc()、free()、およびヒープを変更する可能性のあるその他の関数を呼び出さないでください。
  • printf()、scanf()、および標準入出力を変更する可能性のあるその他の関数を呼び出さないでください。
  • rand()、time() などの他の非再入可能関数を呼び出さないでください。
  • ハードウェア デバイスやファイルなどの共有リソースにアクセスする必要がある場合は、ミューテックスを使用するか、割り込みを無効にしてリソースを保護します。

マルチタスクまたはマルチスレッド環境、特に割り込みハンドラでは、割り込みはいつでも発生する可能性があるため、リエントラント関数は非常に重要です。割り込みハンドラがリエントラントでない場合、データ エラーやシステム クラッシュが発生する可能性があります。リエントラント関数は、プログラムのモジュール性と再利用性の向上にも役立ちます。

再入可能は関数用であり、関数が複数のスレッドによって実行される場合、その関数は再入可能です。たとえば、チケットを取得する例では、スレッド関数 getTickets() はグローバル変数を操作するため、非再入可能関数です。

これが、マルチスレッド コードをテストするときに、それを制御しないと、スレッド関数で出力されたシンボルが前の行まで実行されることがあり、非常に混乱する理由です。その理由は、スケジューラのスケジューリング戦略だけではありません。不確実ですが、 cout 、 printntf がリエントラントではない、つまりスレッドセーフではないためでもあります。スレッドの場合、表示は共有リソースです。もちろん、出力操作をロックすることもできますが、print ステートメントはデータの操作ではなくコンテンツの表示のみに使用するため、通常はこれを行いません。セキュリティの問題は主に、データが変更できないようにすることです。

5.2 スレッドの安全性

Linux では、複数のスレッドが同じコード部分に同時にアクセスし、そのコードがグローバル変数または静的変数を操作する場合、ロック保護がないとスレッドの安全性の問題が発生する可能性があります。

スレッド セーフティの問題は通常、複数のスレッドが同じデータに同時にアクセスすることによって発生します。これらのスレッドがデータを変更すると、相互に干渉し、データの不整合やその他のエラーが発生する可能性があります。

これを回避するには、ロックを使用して重要なセクションを保護します。ロックにより、同時に 1 つのスレッドだけがクリティカル セクションのデータにアクセスできるようになり、スレッドの安全性の問題が回避されます。

5.3 一般的なスレッドの安全でない状況

  1. 对全局变量或静态变量进行操作:如果多个线程并发访问同一个全局变量或静态变量,并且对它进行了修改操作,那么可能会出现线程安全问题。
  2. 使用非线程安全的函数:一些函数(如strtokgmtime)在多线程环境中使用时可能会出现线程安全问题。这些函数通常都有线程安全的替代版本(如strtok_rgmtime_r),应该尽量使用这些替代版本。
  3. 没有正确使用锁:如果多个线程需要并发访问同一块数据,那么应该使用锁来保护这块数据。如果没有正确使用锁,或者锁的粒度不够细,那么可能会出现线程安全问题。
  4. 没有正确处理信号:在多线程程序中,信号处理函数应该尽量简单,并且避免对全局变量或静态变量进行操作。如果信号处理函数没有正确处理这些问题,那么可能会出现线程安全问题。

5.4 常见线程安全的情况

其实就是避免线程不安全的情况。

  1. 对局部变量进行操作:局部变量是每个线程独有的,因此多个线程并发访问同一个函数中的局部变量时不会出现线程安全问题。
  2. 使用线程安全的函数:一些函数(如strtok_rgmtime_r)是线程安全的,可以在多线程环境中安全地使用。
  3. 正确使用锁:如果多个线程需要并发访问同一块数据,那么应该使用锁来保护这块数据。如果正确使用了锁,并且锁的粒度足够细,那么程序就是线程安全的。
  4. 正确处理信号:在多线程程序中,如果信号处理函数能够正确处理信号,并且避免对全局变量或静态变量进行操作,那么程序就是线程安全的。

总之,线程安全通常是通过避免共享数据、使用线程安全的函数、正确使用锁和正确处理信号等方式来实现的。

5.5 常见的不可重入的情况

  1. 使用全局变量或静态变量:如果一个函数使用全局变量或静态变量来保存状态,那么它通常不是可重入的。这是因为全局变量和静态变量会在多次调用之间保持状态,可能会影响函数的结果。
  2. 非再入可能関数の呼び出し: 関数が非再入可能関数を呼び出す場合、通常はそれも再入可能ではありません。これは、非リエントラント関数がグローバル状態に影響を与え、したがって他の関数の結果に影響を与える可能性があるためです。

5.6 一般的なリエントラントの状況

  1. ローカル変数を使用する: 関数が状態を保持するためにローカル変数のみを使用する場合、通常はリエントラントになります。これは、ローカル変数は複数の呼び出しにわたって状態を保持せず、呼び出しごとに新しいローカル変数が作成されるためです。
  2. 非再入可能関数を呼び出さない: 関数が再入可能関数のみを呼び出す場合、通常はそれも再入可能です。これは、リエントラント関数はグローバル状態に影響を与えないため、他の関数の結果に影響を与えることができないためです。

要約すると、通常、再入可能は、グローバル変数または静的変数の使用を回避し、再入可能関数のみを呼び出すなどによって実現されます。

5.7 再入可能性とスレッドセーフの関係

リエントランシーとスレッド セーフティの違いは、リエントランシーは単一スレッド内の動作のみに関係するのに対し、スレッド セーフティは複数のスレッド間の対話に関係することです。リエントラント関数は、スレッドセーフ関数の一種です。

再入可能関数はスレッドセーフである必要があり、その逆も同様です。正しくロック解除された関数はスレッドセーフですが、再入可能関数は保証されない場合があります。これは、リエントラント関数は共有データやグローバル変数を使用しないため、他のスレッドによって干渉されないためです。スレッドセーフ関数は共有データまたはグローバル変数を使用できますが、同期メカニズム (ロック、セマフォなど) を通じてデータの一貫性と正確性を保証します。

たとえば、malloc 関数はスレッドセーフですが、リエントラントではありません。グローバル変数を使用してメモリ割り当てを管理するため、複数のスレッドが同時に呼び出す場合は、データの競合を避けるためにロックする必要があります。ただし、malloc の呼び出し中にスレッドが中断され、割り込みハンドラーも malloc を呼び出した場合、同じスレッドがすでに保持しているロックを取得しようとするため、デッドロックが発生します。したがって、malloc 関数はリエントラントではありません。

もう 1 つの例は printf 関数です。これはスレッドセーフでも再入可能でもありません。共有バッファを使用して文字列を出力するため、複数のスレッドが同時に呼び出すと、出力が混乱したり失われる可能性があります。また、printf の呼び出し中にスレッドが中断され、割り込みハンドラーも printf を呼び出した場合、バッファ オーバーフローやその他のエラーが発生します。したがって、printf 関数はスレッドセーフでもリエントラントでもありません。

別の例として、静的変数を使用して状態を保持する関数は (静的変数を保護するためにロックを使用する場合) スレッドセーフである可能性がありますが、リエントラントではありません (静的変数は複数の呼び出しの間で状態を維持するため)。逆に、ローカル変数を使用して状態を保持する関数は、リエントラントである可能性があります (ローカル変数は呼び出し間で状態を保持しないため) が、必ずしもスレッドセーフであるとは限りません (同時アクセスのマルチスレッドを正しく処理しない場合)。

リエントラントでスレッドセーフな関数を作成することは、プログラムの安定性と効率を向上させる優れたプログラミング手法です。リエントラントかつスレッドセーフな関数を実装するには、次の原則に従う必要があります。

  • 共有データやグローバル変数の使用を避け、ローカル変数やパラメータの受け渡しを使用するようにしてください。
  • 共有データまたはグローバル変数を使用する必要がある場合は、同期メカニズムを使用してそれらを保護し、ロックをできるだけ短くする必要があります。
  • 割り込みハンドラ内から他の関数​​を呼び出す必要がある場合は、これらの関数がリエントラントであり、メイン プログラムでデッドロックや再帰が発生しないことを確認する必要があります。
  • 情報を画面またはファイルに出力する必要がある場合は、出力の混乱や損失を避けるためにアトミック操作またはバッファリング メカニズムを使用する必要があります。

6. デッドロック

6.1 コンセプト

デッドロックとは、実行処理中に複数のスレッドがリソースの競合により待ち状態となり、外部からの力がなければ実行を継続できなくなる現象を指します。デッドロックは通常、複数のスレッドが同時に複数のリソースを要求した場合に発生します。リソースの割り当てが不適切なため、スレッドが互いに待機し、実行を続行できなくなります。

たとえば、スレッド A とスレッド B はそれぞれロック a とロック b を所有していますが、申請したロックはすでに占有されているため、互いのロックを申請する必要があり、最終的にコードを進めることができなくなります。

画像-20230414113350007

注: スレッドの数には 2 が含まれますが、これに限定されません。実際の状況では、多数のロックが存在し、最終的にループを形成する可能性があります。コンピュータでは、自分がロックを申請するという 1 つのロックによってデッドロックが発生することがありますが、このような状況は稀であり、通常はコードの記述が間違っていると考えてください。

[自信] これは私が書いたコードですが、可能ですか?

  1. コード内に複数のロックが存在する場合があります。
  2. ロック a とロック b のコードは非常に離れている可能性があり、コードを作成するときにどこかにロックが追加されたことを忘れる可能性があります。

6.3 例

たとえば、チケットを取得するための前のスレッド関数で、ロックを解放する操作を誤ってロックの申請として記述した場合、これはロックによってデッドロックが発生する状況です。スレッドはロックを解放することができず、待機キュー内のスレッドが常にハングする原因となります。端末から見ると、カーソルは点滅し続けます。

// 线程函数
void* getTickets(void* args)
{
    
    
	ThreadData* td = (ThreadData*)args; 	// 获取参数传递的数据
    // ...
	pthread_mutex_lock(td->_pmtx); 		// 加锁
	// ...
	// pthread_mutex_unlock(td->_pmtx); // 解锁
	pthread_mutex_lock(td->_pmtx); 		// 本来是解锁,写成申请锁
    // ...
}

画像-20230414131108843

ps コマンドを使用して、プロセスのステータスを表示します。

[外部リンク画像の転送に失敗しました。ソース サイトにはリーチ防止メカニズムがある可能性があります。画像を保存して直接アップロードすることをお勧めします (img-sPFaTHzT-1681489006786)(…/…/…/Application Support/typora-user-画像/image-20230414131715204.png)]

Sl+内の 1 つはllock で、プロセスがデッドロック状態にあることを示します。

6.4 ブロック、一時停止、待機

マルチスレッド プログラミングでは、ブロック、一時停止、待機はすべて、スレッドの実行を一時的に停止することを意味します。それらの違いは次のとおりです。

  • ブロッキング: I/O 操作の完了待ちやロックの取得待ちなど、条件が満たされるのを待っている間、スレッドはブロックされます。条件が満たされると、スレッドは自動的に実行を再開します。
  • 一時停止: スレッドが一時停止された場合、スレッドは自動的に実行を再開しませんが、他のスレッドがそのスレッドを明示的にウェイクアップする必要があります。
  • 待機中: スレッドは、特定の条件変数を待機するために wait() メソッドを呼び出すなど、特定の条件が満たされるのを待機しているときに待機状態に入ります。条件が満たされると、スレッドが起動して実行を継続します。

ロックの実装では、スレッドがすでに占有されているロックを取得しようとすると、スレッドはブロックされ、ロック待機キューに追加されます。ロックが解放されると、オペレーティング システムは待機キューから 1 つ以上のスレッドを削除し、それらをウェイクアップして、実行を継続できるようにします。

Linux オペレーティング システムでは、スレッドは軽量プロセスと呼ばれます。スレッドとプロセスはどちらも task_struct 構造体で表され、同じ待機キュー メカニズムを使用でき、実装と使用法は基本的に同じです。ただし、オペレーティング システムでのスレッドとプロセスの管理方法に応じて、異なる待機キューが使用される場合があります。

CPU はタスクを実行するための基盤であるため、タスクを実行する必要があるすべてのスレッドとプロセスにとって必要なリソースは CPU の計算能力です。システムにはさまざまな待機キューがあり、ロック、ディスク、ネットワーク カード、その他のリソースなど、他のリソースを待機しています。

たとえば、プロセスが CPU によってスケジュールされている場合、そのプロセスはロック リソースを使用する必要がありますが、この時点でロック リソースが他のプロセスによって使用されている場合、プロセスの状態は R 状態からある種の状態に変化します。 S 状態などのブロッキング状態になると、プロセスは実行中の待機キューから削除され、ロックを待機しているリソースにリンクされているリソースが待機キューに対応し、CPU は実行中の次のプロセスのスケジュールを継続します。待機列。その後、このロックのリソースを使用する必要があるプロセスがまだある場合、それらのプロセスも実行中の待機キューから削除され、順番にこのロックのリソース待機キューにリンクされます。

ロックを使用しているプロセスが使い果たされるまで、つまりロックのリソースが準備できるまで、この時点でプロセスはロックのリソース待ちキューからウェイクアップされ、プロセスの状態は から変更されます。待機キューを実行するには、CPU がプロセスを再度スケジュールすると、プロセスはロックされたリソースを使用できます。

まとめ

  • オペレーティング システムの観点から見ると、ブロック、サスペンド、待機はすべて、スレッドの実行が一時的に停止することを意味します。オペレーティング システムは、これらのスレッドを CPU スケジューリング キューから削除し、他の準備が完了したスレッドのために CPU 時間を解放します。

  • ユーザーの観点から見ると、ブロック、ハング、待機はすべて、スレッドが一時的に応答を停止する原因となります。ユーザーは、プログラムの実行が遅くなったり、途切れたりすると感じる場合があります。ただし、これらの状態は通常は一時的なもので、条件が満たされるとスレッドは自動的に実行を再開します。

「リソース」は、ハードウェア資源やソフトウェア資源に限定されません。ロックの本質はソフトウェア リソースです。ロックを申請すると、そのロックは他のスレッドによって占有される可能性があります。このとき、他のスレッドが再度ロックを申請すると失敗し、その後ロックが配置されます。このロックのリソース待機キュー。

ロックとロック解除のプロセスで問題が発生する可能性があるため、スレッド関数内でロックを解除するのではなく、スレッドがスレッド関数を実行する前後にロックとロック解除を行ってはいかがでしょうか。そうすれば問題が発生する可能性が低くなります。

スレッド関数の実行全体にわたってロックを保持すると、パフォーマンスの問題が発生する可能性があります。ロックの目的は、共有リソースが複数のスレッドによって同時にアクセスおよび変更されないように保護することです。スレッドが実行中ずっとロックを保持している場合、現在のスレッドが実際に共有リソースを使用していない場合でも、他のスレッドはこれらの共有リソースにアクセスできません。

クリティカルセクションの長さが長すぎると、効率の問題が発生する可能性があります。たとえば、スレッドがクリティカル セクション内で長時間滞在すると、クリティカル セクションに入ろうとする他のスレッドがブロックされ、パフォーマンスが低下し、応答時間が長くなる可能性があります。チケット取得の例では、重要なセクションは十分に短いですが、それでも効率は大幅に低下します。したがって、一般的には、クリティカル セクションの長さをできるだけ短くし、クリティカル セクションで必要な操作のみを実行することをお勧めします。

したがって、一般的には、共有リソースにアクセスする必要がある場合にのみミューテックスをロックし、アクセスが完了したらすぐにロックを解除して、クリティカル セクションの長さを厳密に制限することをお勧めします。これにより、ロック時間が最小限に抑えられ、プログラムの同時実行パフォーマンスが向上します。

6.4 デッドロックの必要条件

デッドロックとは、プロセスまたはスレッドのグループが互いのリソースを待機しているために実行を続行できない状況を指すことがわかっています。デッドロックは、システムの機能を低下させたり、応答しなくなったりする可能性があるため、深刻な問題です。したがって、デッドロックの原因と解決策を理解することが非常に重要です。

Linuxではデッドロックが発生するためには、以下の4つの必要条件を満たす必要があります。

  1. 相互に排他的な条件: 各リソースはプロセスまたはスレッドに割り当てられているか、使用可能であり、同時に複数のプロセスまたはスレッドによって占有されることはできません。
  2. 所有および待機条件 (リクエストとホールド): すでにリソースを保持しているプロセスまたはスレッドは、すでに保持 (ホールド) しているリソースを解放することなく、新しいリソースをリクエストできます。
  3. 非プリエンプティブ条件: プロセスまたはスレッドに割り当てられたリソースは、他のプロセスまたはスレッドによって強制的に奪われることはできません。プロセスまたはスレッドのみが自発的にリソースを解放できます。
  4. 循環待機状態: プロセスまたはスレッドの集合があり、それぞれが次のプロセスまたはスレッドによって占有されるリソースを待機し、循環チェーンを形成します。

デッドロックを回避する

デッドロックの必要条件の違反

これら 4 つの条件のいずれかが当てはまらない場合、デッドロックは発生しません。したがって、デッドロックを防止または回避する方法は次のとおりです。

  • 次の 4 つの前提条件のうち 1 つ以上に違反しています。

    • セマフォまたはミューテックスを使用してリソースへの相互排他的アクセスを実現し、複数のプロセスまたはスレッドが同時に同じリソースをめぐって競合するのを防ぎます。

    • バンカー アルゴリズムまたは事前割り当てアルゴリズムを使用してリソースを割り当て、プロセスまたはスレッドがリソースを占有しているときに新しいリソースを要求してリソースが不足することを回避します。

    • 優先順位メカニズムまたはタイムアウト メカニズムを使用して、リソースをプリエンプトし、さまざまな種類のロックにさまざまな優先順位を割り当て、優先順位に従ってロックを取得します。優先度の低いプロセスやスレッドが長時間リソースを占有したり、優先度の高いプロセスやスレッドをブロックしたりしないようにします。

    • トポロジカルなソートまたは順序付けされた割り当て方法を使用してリソースを割り当て、プロセスまたはスレッド間の循環待機チェーンの形成を回避し、リソースを一度に割り当てます。または、重要なリソースにアクセスした直後にロックを完全に解放します。

  • ロックタイムアウトの設定:ロックごとにタイムアウト時間を設定し、タイムアウト時間内にロックが取得できない場合は取得を中止し、取得したロックを解放してロックが解放されない事態を回避します。

  • デッドロック検出アルゴリズムを使用する: デッドロック検出アルゴリズムを定期的に実行して、システムにデッドロックがあるかどうかを検出します。デッドロックが検出された場合は、それを解決するために適切なアクションが取られます。

当面は、行き詰まりを打開するために必要な4つの条件を理論的に理解するだけでよく、他の方法は実践的に学んでいきます。

trylock関数を使う

Linux では、trylock は非ブロッキング関数 (即時) であり、ミューテックスのロックを試行するために使用されます。ミューテックスが現在どのスレッドによってもロックされていない場合、呼び出し元のスレッドがミューテックスをロックします。ミューテックスが現在別のスレッドによってロックされている場合、関数は失敗し、ブロックせずにすぐに戻ります。つまり、スレッドはロックを申請する前に、自身のロックを解放しようとします。これは、以前に適用されたロックを放棄するのと同じです。他のスレッドがこのロックを取得して実行を終了した後、このロックを再度申請できます。 .ロックされています。したがって、これは、デッドロックを形成するための 2 番目の必要条件、つまり trylock 関数に指定されたスレッドに「控えめな」態度を与え、相手に最初にロックを使用させるという条件を破棄します。

たとえば、pthread ライブラリでは、pthread_mutex_trylock関数を使用してミューテックスのロックを試みることができます。ロックが成功した場合は 0 を返し、それ以外の場合はエラー コードを返します。

man pthread_mutex_trylock を通じてその説明を表示できます。

画像-20230414135154624

この段落では、pthread_mutex_trylock関数の動作について説明します。これはpthread_mutex_lockfunction に似ていますが、重要な違いが 1 つあります。ミューテックスが現在いずれかのスレッド (現在のスレッドを含む) によってロックされている場合、関数はブロックせずにすぐに戻ります。

さらに、ミューテックスが のタイプでありPTHREAD_MUTEX_RECURSIVE、呼び出し元のスレッドによって現在所有されている場合、ミューテックスのロック カウントは 1 ずつ増加し、pthread_mutex_trylock関数はすぐに成功を返します。

つまり、pthread_mutex_trylockこの関数はミューテックスのロックを試みるために使用され、ミューテックスが現在ロックされている場合、関数はブロックせずにすぐに戻ります。ミューテックスのタイプが同じでPTHREAD_MUTEX_RECURSIVE、現在呼び出し元のスレッドによって所有されている場合、関数はミューテックスのロック カウントをインクリメントし、すぐに成功を返します。

7. スレッドの同期

7.1 主要な概念

同期する

ポイント 2 で述べたように、同期とは、複数のイベントが特定の順序で実行されることを意味します。したがって、スレッドの場合、スレッド同期とは、複数のスレッドが共有リソースに正しくアクセスできるように、特定の順序で実行するように調整することを指します。これには通常、スレッド間の実行順序を制御するために、ミューテックス、セマフォ、条件変数などの同期メカニズムを使用する必要があります。

競合状態

競合状態とは、マルチスレッド プログラムにおいて、複数のスレッドが同時に共有リソースにアクセスして変更することにより、プログラムの実行結果がスレッドのスケジュール順序に依存することを意味します。これにより、プログラムが不定に動作したり、誤った結果が生成されたりする可能性があります。

競合状態を回避するには、複数のスレッド間で実行順序を調整する同期メカニズムが必要です。たとえば、ミューテックスを使用すると、共有リソースがアクセスされるまでアクセスを保護できます。このようにして、常に 1 つのスレッドのみが共有リソースにアクセスできるため、競合状態が回避されます。

7.2 インポート

チケット取得の例に関する限り、スレッドがロックとロック解除の間に一度に 10,000 枚のチケットを取得する可能性があります。これは発生する可能性があり、このスレッドの優先順位は比較的高い可能性があります。この状況は許可されます。確かにそうですが、それは無理があります。なぜそれは正しいが不合理だと言えるのですか?

たとえば、シャオミンは自分の携帯電話を見に携帯電話店に行きましたが、初めて店員に「この携帯電話は来月発売されます」と言った場合、シャオミンは翌日にもう一度尋ね、その後、 3日目...これは正しいですが、彼が店員に尋ねるたびに、シャオミンに対処するために時間を費やすのは明らかに無意味です。そして、人生でこれほど深刻な手術はありません。なぜこのアプローチが正しいのでしょうか?これは同期メカニズム (上記の概念を参照) に沿っているため、共有リソースとロックが占有されている場合にのみ要求を続けることができます。この例は、スレッド同期メカニズムの意味を理解するのに役立ちます。

したがって、スレッドの場合、重要なリソースにアクセスするために毎回ロックを申請したい場合、オペレーティング システムは「他のスレッドは内部でビジー状態なので、そこに留まって (キューで待機する)」と通知します。常にすべての場所でロックが申請されていますが、これはスレッドにとっては無意味な操作です。これは、単純にロックする場合、スレッドのスケジューリングが不合理になる場合です。

  • 個々のスレッドの優先度が高い場合、毎回ロックを申請できますが、ロックを申請した後は何もせず、無意味にロックの申請と解放を繰り返すため、他のスレッドが長時間ロックを競合しない可能性があります。問題。

ロックにより、1 つのスレッドだけがクリティカル セクション コードを実行してクリティカル リソースに同時にアクセスすることが保証されますが、すべてのスレッドがクリティカル リソースにアクセスできることは保証できません。したがって、効率的なスレッド同期を実現するために、ロックをより意味のあるものにする同期メカニズムが必要です。

ロックを申請する目的は重要なリソースにアクセスすることであるため、ロックがなければアクセスを取得できないため、説明中の「ロックの申請」と「重要なリソースへのアクセスの申請」は同等です。見てみると、それらは連続しています。

7.3 スレッドの同期

スレッドの同期には通常、複数のスレッド間の実行順序を制御するために、セマフォや条件変数などに加えて、チケット取得の例のミューテックスなどのいくつかの同期メカニズムの使用が含まれ、スレッドの動作は同期に依存します。使用するメカニズム。ミューテックスを例に挙げます。

  • スレッド アプリケーション ロックの失敗: スレッドが pthread_mutex_lock 関数の呼び出しによるロックの適用に失敗した場合、そのスレッドは他のスレッドがロックを解放するまでブロックされます。pthread_mutex_trylock 関数を使用すると、ロックの適用が失敗した場合、関数は直ちにエラー コードを返します。

  • スレッドはロックを解放します。ロックを待っている他のスレッドを起動します。たとえば、スレッドが pthread_mutex_unlock 関数を呼び出してロックを解放すると、ロックを待機している他のスレッドが起動し、ロックを取得するために競合し続けます。

ミューテックスの動作と原理については説明しましたが、次に条件変数について説明します。

7.4 条件変数

クリティカルリソースにアクセスするためのロックを申請する場合には、クリティカルリソースが存在することが前提となるため、まずクリティカルリソースが存在するかどうかを確認する必要がある。検出動作自体はクリティカルリソースへのアクセスであるため、クリティカルリソースの安全性を確保するために、クリティカルリソースのロックとアンロックの間(クリティカルセクション)で検出を行う必要があります。従来はリソースの準備ができているかどうかを確認する方法でしたが、準備ができていない場合はロックの適用に失敗します。スレッドの動作に制限がなければ、常にロックの申請と解放が頻繁に行われ、意味のない処理が行われてしまいます。そのため、この条件セットに、条件がスレッドの動作を制限する準備ができているかどうかを示すフラグを設定します。このフラグを条件変数と呼びます。

スレッドの動作を制限するにはどうすればよいですか? 携帯電話ストアの例へのリンク:

  1. スレッドに重要なリソースの準備ができているかどうかを頻繁にチェックさせず、待機させます。
  2. 条件が整ったら、待機中のスレッドに通知し、スレッドにロックを適用させ、重要なリソースにアクセスさせます。

条件変数は、スレッド間で共有されるグローバル変数を使用した同期のメカニズムであり、特定のリソースの準備ができているかどうかを記述するために使用されるデータ記述です。条件変数を使用すると、1 つ以上のスレッドが何らかの共有状態の変更を待機しながら、同時に取得したミューテックスを解放できるため、他のスレッドにその状態を変更する機会が与えられます。共有状態が変化すると、1 つ以上の待機中のスレッドが起動され、ミューテックスを再取得して、実行を継続できます。

条件変数には主に 2 つの操作があります。

  • 待機中の操作: スレッドが条件変数の「条件」が確立されるのを待ってハングすることを示します。パラメータとしてミューテックスと条件変数を指定する必要があります。
  • 待機動作: 「条件」が成立した後、「条件」を待っているスレッドを別のスレッドがウェイクアップすることを示します。
    1. ミューテックスを解放し、他のスレッドが共有リソースにアクセスできるようにします。
    2. 現在のスレッドをブロックし、条件変数の待機キューに追加します。
    3. シグナルを受信すると、現在のスレッドを起動し、ミューテックスを再取得します。
    4. 条件が実際に真であるかどうかを確認し、そうでない場合は、上記の手順を繰り返します。

ウェイクアップ操作とは、スレッドが特定の条件が確立されたことを他のスレッドに通知することを意味し、条件変数をパラメータとして提供する必要があります。ウェイクアップ動作は、シングルショットとブロードキャストの 2 つのタイプに分類できます。シングルショット信号は待機中のスレッドを 1 つだけウェイクアップしますが、ブロードキャスト信号は待機中のすべてのスレッドをウェイクアップします。ウェイクアップ操作はミューテックスを保持する必要はありませんが、通常は共有状態を変更した後に実行されます。

ウェイクアップ動作は信号(シグナル)動作とも呼ばれます。

原則として

条件変数を使用するには、次の原則に従う必要があります。

  • 共有状態の一貫性を保護するには、条件変数をミューテックスと組み合わせて使用​​する必要があります。
  • 競合状態を避けるために、ミューテックスを保持しながら待機操作を実行する必要があります。
  • シグナル操作はいつでも実行できますが、誤ったウェイクアップやウェイクアップの見逃しを避けるために、ミューテックスを保持しているときに実行するのが最適です。
  • 待機操作では、while ループを使用して、偽のウェイクアップまたは複数のウェイクアップが発生した場合の条件をチェックする必要があります。
  • 条件変数は pthread_cond_init 関数で初期化し、pthread_cond_destroy 関数で破棄する必要があります。

条件変数は、プロデューサー/コンシューマー モデル、リーダー/ライター モデル、スレッド プールなど、さまざまな複雑なシナリオの実装に使用できる強力で柔軟な同期ツールです。条件変数を使用する場合は、条件を正しく設定および確認し、シグナリングと待機の責任を適切に分散するように注意する必要があります。

cond 関数ファミリー

pthread_cond ファミリの関数は、Linux でのスレッド同期用の関数セットです。それらには次のものが含まれます。

  • pthread_cond_init: 条件変数を初期化します。
  • pthread_cond_wait: ブロッキングは条件変数が満たされるまで待機します。
  • pthread_cond_signal: 条件変数を待っているスレッドを起動します。
  • pthread_cond_broadcast: 条件変数を待機しているすべてのスレッドを起動します。
  • pthread_cond_timedwait: ブロックし、指定された時間まで条件変数が満たされるまで待機します。
  • pthread_cond_destroy: 条件変数を破棄します。

これらの関数の戻り値は同じです。関数が正常に実行されると、すべて 0 が返されます。それ以外の戻り値はエラーを示します。

pthread_cond_init

プロトタイプ:

int pthread_cond_init(pthread_cond_t *restrict cond, const pthread_condattr_t *restrict attr);

パラメータ:

  • cond: 初期化する必要がある条件変数。
  • attr: 条件変数の属性を初期化します。通常、デフォルトの属性を示すために NULL/nullptr に設定されます。

ミューテックスの定義と同様に、 pthread_cond_init 関数を呼び出して条件変数を初期化することは、動的割り当てと呼ばれます。さらに、静的に割り当てることもできます (通常はグローバルに使用されます)。

cpthread_cond_t cond = PTHREAD_COND_INITIALIZER; // 它是一个宏

注: 静的に割り当てられた条件変数は、関数を呼び出して手動で破棄する必要はありません。

pthread_cond_destroy

プロトタイプ:

int pthread_cond_destroy(pthread_cond_t *cond);

パラメータ:

  • cond: 破棄する必要がある条件変数。

pthread_cond_wait

プロトタイプ:

int pthread_cond_wait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex);

パラメータ:

  • cond: 待機する条件変数。
  • mutex: 現在のスレッドが配置されているクリティカル セクションに対応するミューテックス。

pthread_cond_broadcast と pthread_cond_signal

プロトタイプ:

int pthread_cond_broadcast(pthread_cond_t cond);
int pthread_cond_signal(pthread_cond_t cond);

パラメータ:

  • cond: cond 条件変数の下で待機しているスレッドを起動します。

違い:

  • pthread_cond_signal 関数は、待機キュー内の最初のスレッドを起動するために使用されます。
  • pthread_cond_broadcast 関数は、待機キュー内のすべてのスレッドを起動するために使用されます。

フレーム

次の例では、複数のスレッドが異なるタスクを実行しており、それらのスレッドがタスクを実行している間、他のスレッドが待機しています。関数ポインターの配列は、さまざまなスレッドの関数を格納するために使用され、同様に、スレッドに渡される情報をオブジェクトに格納できます。ロックする前に、最初にフレームワークを作成します。この例では 3 つのスレッドを作成し、各スレッドは異なるタスクを実行し、スレッド関数に渡すパラメーターとして関数ポインター型を使用します。

#include <iostream>
#include <string>
#include <unistd.h>
#include <pthread.h>
using namespace std;

#define THREAD_NUM 3						// 线程数量
typedef void (*func_t)(const string& name); // 定义一个函数指针类型
class ThreadData
{
    
    
public:
	// 构造函数
	ThreadData(const string& tname, func_t func)
	: _tname(tname)
	, _func(func)
	{
    
    }
public:
	string _tname;			// 线程名
	func_t _func;			// 线程函数指针
};
// 线程函数1
void tFunc1(const string& tname)
{
    
    
	while(1) 
	{
    
    
		cout << tname << "正在运行任务A..." << endl;
		sleep(1);
	}
}
// 线程函数2
void tFunc2(const string& tname)
{
    
    
	while(1) 
	{
    
    
		cout << tname << "正在运行任务B..." << endl;
		sleep(1);
	}
}
// 线程函数3
void tFunc3(const string& tname)
{
    
    
	while(1) 
	{
    
    
		cout << tname << "正在运行任务C..." << endl;
		sleep(1);
	}
}
// 跳转函数
void* Entry(void* args)
{
    
    
	ThreadData* td = (ThreadData*)args; 	// 强转获取参数传递的数据
	td->_func(td->_tname); 					// 调用线程函数
	delete td; 								// 销毁数据
	return nullptr;
}
int main()
{
    
    
	pthread_t t[THREAD_NUM];				// 创建线程ID
	func_t f[THREAD_NUM] = {
    
    tFunc1, tFunc2, tFunc3}; 		//保存线程函数地址
	for(int i = 0; i < THREAD_NUM; i++)		
	{
    
    
		string tname = "thread["; 			// 线程名
		tname += to_string(i + 1); tname += "]";
		ThreadData* td = new ThreadData(tname, f[i]); 		// 创建保存数据的对象
		pthread_create(t + i, nullptr, Entry, (void*)td); 	// 创建线程的同时将名字和数据对象传递
	}
	for(int i = 0; i < THREAD_NUM; i++)		// 等待线程
	{
    
    
   		pthread_join(t[i], nullptr);
   		cout << "thread[" << t[i] << "]已退出..." << endl;
	}

    return 0;
}

ステップ:

  1. 関数ポインタ型 func_t が定義されており、これは型 const string& のパラメータを受け取り、void を返します。このようにして、さまざまな関数をパラメータとしてスレッド関数に渡すことができます。

  2. クラス ThreadData が定義されており、これはスレッド名やスレッド関数ポインターを含むスレッド データをカプセル化するために使用されます。これら 2 つのメンバー変数を初期化するコンストラクターがあります。

  3. 3 つのスレッド関数 tFunc1、tFunc2、および tFunc3 が定義されており、それぞれタスク A、B、および C を実行し、スレッド名とタスク情報を出力します。ここでは、sleep(1) 関数を使用して各スレッドを 1 秒間一時停止し、出力を観察します。

  4. 次に、スレッドを開始するための pthread_create 関数の 3 番目のパラメーターであるジャンプ関数 Entry を定義します。渡されたデータを取得します。オブジェクト内のスレッド関数を呼び出し、スレッド名を引数として渡します。最後に、td オブジェクトを削除します (main 関数では新しいオブジェクトであるため)。

    関数アドレス +()演算子は、このアドレスで関数を呼び出すことと同じです。

  5. main 関数では、スレッド ID とスレッド関数のアドレスを保存するために配列が使用されます。ループ内で 3 つのスレッドを順番に作成し、pthread_create 関数を呼び出して、スレッド情報を渡します。このようにして、名前とデータ オブジェクトがジャンプ関数 Entry に渡されます。スレッドの作成に失敗した場合は、エラー メッセージを出力してプログラムを終了します。最後に 3 つのスレッドがループ内で待機しています。

pthread_create 関数を使用する場合、パラメータを void* 型にキャストし、jump 関数で元の型に変換する必要があることに注意してください。これは以前にも強調されました。

画像-20230414173955214

ただし、このプログラムは完全ではありません。スレッド関数が実行するタスクが指定されておらず、手動でのみ終了でき、単なるフレームワークにすぎません。

ミューテックス、条件変数

ミューテックスや条件変数を解放する場合は、適用順序と逆の順序で解放してください。つまり、最初にミューテックスを申請し、次に条件変数を申請する場合、解放するときは、最初に条件変数を解放し、次にミューテックスを解放する必要があります。つまり、最初に要求されたリソースは後で解放される必要があります。

これはデッドロックを回避するために行われます。デッドロックとは、2 つ以上のスレッドが互いのリソースの解放を待機しており、スレッドの進行が妨げられている状況です。すべてのスレッドが同じ順序でリソースの申請と解放を行うと、デッドロックを回避できます。例えば:

int main()
{
    
    
    pthread_mutex_t mtx;
    pthread_cond_t cond;
    pthread_mutex_init(&mtx, nullptr);
    pthread_cond_init(&cond, nullptr);
	// ... 
    pthread_mutex_destroy(&mtx);
    pthread_cond_destroy(&cond);

    return 0;
}

注: 条件変数は、スレッド セーフのためにミューテックスとともに使用されることがよくあります。

拡張情報

条件変数とミューテックスを定義したため、フレームワークの ThreadData クラスは要件を満たさなくなりました。各スレッドが条件変数とミューテックスによって制約されるようにするには、これら 2 つのことをスレッドに認識させる必要があります。そのため、スレッドに渡す情報内容を拡張する必要があります。

typedef void (*func_t)(const string& name, // 定义一个函数指针类型
					   pthread_mutex_t* pmtx, 
					   pthread_cond_t* pcond); 

class ThreadData
{
    
    
public:
	// 构造函数
	ThreadData(const string& tname, func_t func, pthread_mutex_t* pmtx, pthread_cond_t* pcond)
	: _tname(tname)
	, _func(func)
	, _pmtx(pmtx)
	, _pcond(pcond)
	{
    
    }
public:
	string _tname;			// 线程名
	func_t _func;			// 线程函数指针
	pthread_mutex_t* _pmtx; // 互斥锁指针
	pthread_cond_t* _pcond; // 条件变量指针
};

メイン関数とスレッド関数の間のソフトウェア層としてのエントリは、さらにいくつかのパラメータを渡す必要があり、スレッド関数はミューテックス アドレスと条件変数のアドレスも使用する必要があります。

// 跳转函数
void* Entry(void* args)
{
    
    
    // ...
	td->_func(td->_tname, td->_pmtx, td->_pcond); // 调用线程函数
    // ... 
}

このようにして、各スレッドは同じメモリ内のロックを取得し、異なるスレッド関数を呼び出すことができます。ここではグローバルロックとして設定できるので、わざわざパラメータを渡す必要はありませんが、グローバル変数自体がうまく制御されていない場合、セキュリティ上の問題が発生します。

スレッド関数の 1 つを例として挙げます。

void tFunc1(const string& tname, pthread_mutex_t* pmtx, pthread_cond_t* pcond)
{
    
    
    while(1) 
    {
    
    
        pthread_mutex_lock(pmtx);		// 加锁
        pthread_cond_wait(pcond, pmtx);	// 等待条件(失败就进入等待队列)
        cout << tname << "正在运行任务A..." << endl;
        pthread_mutex_unlock(pmtx);		// 解锁
        sleep(1);
    }
}
int main()
{
    
    
	// ...
    ThreadData* td = new ThreadData(tname, f[i], &mtx, &cond); 		// 创建保存数据的对象
    // ...
    return 0;
}

pthread_cond_wait 関数を呼び出すスレッドは、R->S からのプロセスと同様に、すぐにブロックされます。ブロックされたスレッドは、最初は待機キューに入れられます。同じ条件変数の下で、上記のコードは各スレッドを最初から制限なく直接ブロックします。スケジューラのスケジューリング戦略は不確かですが、すべてのスレッドが待機キューにある場合、その実行順序は決定されています (キューがFIFO)。この実行順序はキュー内の順序 (abcd など) です。タスクが完了していない限り、後続のスレッドのスケジュール順序は固定する必要があります。スケジューラは先頭のスレッドのみを取得するためです。タスクを実行するキュー。この順序はキューによって決まります。データ構造によって決まり、スケジューラのスケジュール ポリシーには影響されません。

スレッドを起こしてください

条件変数ウェイクアップ

main関数の作成・待機ロジックの途中に、スレッドを制御するロジックを追加することができます。たとえば、待機中のスレッドを起動するには pthread_cond_signal 関数を使用します。そのパラメータは条件変数のアドレスであり、条件変数の機能はシグナルを送信する条件変数を指定することです。

pthread_cond_signal は、待機中にブロックされているスレッドがない場合、成功時に 1 を返します。

pthread_cond_signal は「条件変数シグナル」と呼ばれ、シグナルの機能はウェイクアップすることなので、シグナルをウェイクアップと呼ぶことに慣れています。

int main()
{
    
    
	// 创建线程
	sleep(5);
	while(1)
	{
    
    
		cout << "唤醒线程..." << endl;
		pthread_cond_signal(&cond);			// 唤醒线程
		sleep(1);
	}
	// 等待线程
    return 0;
}

sleep(5) の機能は、スレッドの作成後、スレッドが pthread_cond_signal 関数を実行するのに十分な時間を確保し、すべてのスレッドが待機状態になるようにすることです。

sleep(1) の目的は、現象をよりよく観察するためにスレッドをリズミカルに起動することです。

出力:

画像-20230414193107668

前回ロックを行わなかった場合よりも出力結果がきれいになり、印刷内容が混在することもなくなりました。さらに、スレッドは特定の順序でスケジュールされます。

最初の 3 ラウンドが ABC で、次に CBA であるのはなぜですか?

ただし、最初は各スレッドが条件変数 cond がトリガーされるのを待っています。main 関数では、pthread_cond_signal を使用して、cond を待機しているスレッドを起動します。この関数は、待機キュー内の最初のスレッドを FIFO 順にウェイクアップします。ただし、目覚めたスレッドが最初に実行されるとは限りません。上記のコードは、sleep(1) を使用して、スレッドを起動する時間間隔を制御します。ただし、これは各ウェイクアップ スレッドが mtx ロックを取得して実行できることを保証するものではありません。この時点で別のスレッドがすでに mtx ロックを保持している場合、目覚めたスレッドはまだ待機する必要があります。したがって、待機キュー内のスレッドが順番に起動されたとしても、それらのスレッドが実行される順序は依然として不定です。

ウェイクアップされるスレッドがキューの先頭を待機しているスレッドであることを確認するにはどうすればよいでしょうか?

印刷順序が常に ABCABCABC になるようにしたい場合は、カウンターを使用してスレッドの実行順序を制御できます。たとえば、グローバル変数 intturn を定義して 0 に初期化できます。次に、各スレッド関数で、turn の値をチェックして、現在実行する必要があるかどうかを判断できます。

たとえば、tFunc1 では、while ループ内に while ステートメントを追加できます。これにより、turn == 0 になった場合にのみループが終了し、印刷操作が実行されます。同様に、tFunc2 と tFunc3 に、turn == 1 とturn == 2 をそれぞれチェックする同様の while ステートメントを追加します。

ターンへのアクセスはミューテックスによって保護され、各スレッド関数の出力が終了した後に 1 ずつ増加する必要があります。次に、pthread_cond_broadcast を使用して、条件変数を待っているすべてのスレッドを起動する必要があります。このようにして、各スレッドはあらかじめ決められた順序で実行されます。

変更された tFunc1 関数の例を次に示します。

void tFunc1(const string& tname, pthread_mutex_t* pmtx, pthread_cond_t* pcond)
{
    
    
    while(1) 
    {
    
    
        pthread_mutex_lock(pmtx);
        while(turn != 0)
        {
    
    
            pthread_cond_wait(pcond, pmtx);
        }
        cout << tname << "正在运行任务A..." << endl;
        turn = (turn + 1) % 3;
        pthread_cond_broadcast(pcond);
        pthread_mutex_unlock(pmtx);
        sleep(1);
    }
}

画像-20230414200819582

この例では、各スレッドは印刷前にturnの値をチェックします。turn の値が事前に設定された値と等しくない場合、スレッドは条件変数を待ちます。スレッドは印刷を終了すると、turn の値を更新し、条件変数を待機しているすべてのスレッドを起動します。これにより、他のスレッドが実行を継続できるようになります。

ここではスレッドのスケジューリングを制御する方法を紹介しますが、スレッドを固定の順序でスケジュールしたい場合には、セマフォを使用してスレッドの実行順序を制御することもできます。セマフォは、複数のスレッドまたはプロセスを同期するためのツールであり、複数のスレッドが所定の順序で実行されるようにするために使用できます。

セマフォについては次のセクションで説明します。

たとえば、グローバル変数 sem_t sem を定義し、sem_init(&sem, 0, 1) を使用して main 関数で初期化できます。次に、各スレッド関数で sem_wait(&sem) を使用してセマフォを待機し、セマフォの値が 0 より大きい場合にのみ実行を続行できます。印刷操作を実行した後、他のスレッドが実行を継続できるように、sem_post (& sem) を使用してセマフォを解放する必要があります。

変更された tFunc1 関数の例を次に示します。

void tFunc1(const string& tname)
{
    
    
    while(1) 
    {
    
    
        sem_wait(&sem);
        cout << tname << "正在运行任务A..." << endl;
        sem_post(&sem);
        sleep(1);
    }
}

この例では、各スレッドは印刷する前にセマフォを待機します。セマフォの初期値は 1 であるため、セマフォを取得して実行を継続できるスレッドは 1 つだけです。現在のスレッドが印刷を終了してセマフォを解放するまで、他のスレッドはブロックされます。

このようにして、キューの先頭を待っているスレッドが毎回起動され、所定の順序で実行されることが保証されます。

条件変数ブロードキャスト

以下は、条件変数ブロードキャストを使用し、ブール フラグ ビット quit (デフォルトは false) をグローバルに設定する、変更されたコード例です。スレッドを起動するロジックが終了したら、bool を true に設定します。これは、スレッドがタスクの実行を終了して終了したことを示します。

次に、スレッド関数の while 条件を変更して、while(!quit)スレッドが終了する前にそのロジックを実行することを示す必要があります。

// 为了阅读体验,省略了未修改的部分
// 并省略了tFunc2和tFunc3,它们是类似的。
volatile bool quit = false;

// 线程函数1
void tFunc1(const string& tname, pthread_mutex_t* pmtx, pthread_cond_t* pcond)
{
    
    
    while(!quit) 
    {
    
    
        pthread_mutex_lock(pmtx);		// 加锁
        pthread_cond_wait(pcond, pmtx);	// 等待条件(失败就进入等待队列)
        cout << tname << "正在运行任务A..." << endl;
        pthread_mutex_unlock(pmtx);		// 解锁
        sleep(1);
    }
}
// ...
int main()
{
    
    
	// ...
    for(int i = 0; i < THREAD_NUM; i++)
    {
    
    
        string tname = "thread[";
        tname += to_string(i + 1); tname += "]";
        ThreadData* td = new ThreadData(tname, f[i], &mtx, &cond);
        pthread_create(t + i, nullptr, Entry, (void*)td);
    }

    sleep(5);

    cout << "----线程控制逻辑开始----" << endl;
    int count = 5;
    while(count)
    {
    
    
        cout << "唤醒线程..." << count-- << endl;
        pthread_cond_broadcast(&cond);
        sleep(1);
    }

    cout << "----线程控制逻辑结束----" << endl;
    quit = true;
    
    for(int i = 0; i < THREAD_NUM; i++)
    {
    
    
        pthread_join(t[i], nullptr);
        cout << "thread[" << t[i] << "]已退出..." << endl;
    }
	// ...
    return 0;
}

この例では、各スレッド関数で、pthread_cond_wait が呼び出される前に、ready が 1 ずつインクリメントされます。main 関数では、while ループを使用して、すべてのスレッドが待機キューに入るのを待ちます。Ready の値がスレッドの数と等しい場合、pthread_cond_broadcast が呼び出され、すべてのスレッドが一度に起動されます。

画像-20230414210727667

しかし、しばらくするとスタックし、各ラウンドでスケジュールされるスレッドの順序も異なります。[スケジューラによっては] 複数回実行すると、結果が異なる場合もあります。

画像-20230414211118603

スレッド関数でスリープを削除した場合:

画像-20230414211229331

順番に見えますね。pthread_cond_broadcast を pthread_cond_signal に置き換えると、次のようになります。

画像-20230414211423795

  1. pthread_cond_signal に切り替えた後は、毎回 1 つのステートメントのみが出力され、pthread_cond_broadcast が待機キュー内のすべてのスレッドを同時に起動することが確認されます。
  2. pthread_cond_broadcast が呼び出されると、条件変数を待機しているすべてのスレッドが起動されます。ただし、必ずしも所定の順序で実行されるとは限りません。これは、スレッドがウェイクアップされても、実行を継続するにはミューテックスを取得する必要があるためです。この時点で別のスレッドがすでにミューテックスを保持している場合、目覚めたスレッドはまだ待機する必要があります。

スレッドがあらかじめ決められた順序で実行されるようにしたい場合は、上記の方法を使用してスレッドの実行順序を制御できます。このコードの最大の問題は、タスクを実行するためにスレッドを起動するためにどのような方法が使用されても、スレッドが実行を終了して終了した後でも、プログラムを終了できないことです。この問題の原因は、スレッド関数が不完全であることです。

このコードの場合:

while(!quit) 
{
    
    
    pthread_mutex_lock(pmtx);		// 加锁
    pthread_cond_wait(pcond, pmtx);	// 等待条件(失败就进入等待队列)
    cout << tname << "正在运行任务A..." << endl;
    pthread_mutex_unlock(pmtx);		// 解锁
    sleep(1);
}

pthread_cond_wait を呼び出す前に、まずクリティカル リソースの準備ができているかどうかを確認する必要があり、検出アクション自体はクリティカル リソース リングにアクセスしています。重要なリソースの準備ができていない場合は、pthread_cond_wait 関数が呼び出されて、スレッドがブロック状態に入り、待機キューに入ってウェイクアップを待ちます。言い換えると、pthread_cond_wait は、ロックとロック解除の間に実行する必要があります。クリティカル セクションはできる限り短く、クリティカル リソースにアクセスするすべてのコードが完全に含まれると規定しているため、pthread_cond_wait 関数が呼び出されるとき、スレッドはクリティカル リソース内になければなりません。現時点では、検出操作自体が重要なリソースにあるためです。

クリティカル リソースを申請する前は、スレッドはクリティカル リソースがどのような状態にあるかは認識されず、クリティカル リソースの検出に入ったときにのみ認識されます。リソースの準備ができていないことが検出された場合、スレッドは待機し、効率が低下するため、無駄にロックの申請と解放を繰り返すことはありません。

したがって、特定のニーズに応じて pthread_cond_wait 関数を呼び出す前にクリティカル リソースが準備完了であるかどうかを判断できますが、クリティカル リソースが準備完了の状態を説明する記述をここで見つけるのは難しいため、グローバル変数 ready を使用することもできます。検出操作の代わりに、初期値は false であり、ready が false の場合にのみ pthread_cond_wait 関数が呼び出されます。

void tFunc1(const string& tname, pthread_mutex_t* pmtx, pthread_cond_t* pcond)
{
    
    
    while(!quit) 
    {
    
    
        pthread_mutex_lock(pmtx);		// 加锁
        if(!ready)						// 等待条件(失败就进入等待队列)
        	pthread_cond_wait(pcond, pmtx);	
        cout << tname << "正在运行任务A..." << endl;
        pthread_mutex_unlock(pmtx);		// 解锁
    }
    sleep(1);
}

main 関数では、カウンタ count の初期値は 3 です。count==1 のとき、ready の値は false に設定されます。同時に、count を使用して、ループ内ですべてのスレッドを一度にウェイクアップします。 pthread_cond_wait 関数を使用して現象を確認します。

このプログラムのコード:

#include <iostream>
#include <string>
#include <pthread.h>
#include <unistd.h>

using namespace std;

#define THREAD_NUM 3						// 线程数量
typedef void (*func_t)(const string& name, 	// 定义一个函数指针类型
					   pthread_mutex_t* pmtx, 
					   pthread_cond_t* pcond); 

volatile bool quit = false;
volatile bool ready = false;

class ThreadData
{
    
    
public:
	// 构造函数
	ThreadData(const string& tname, func_t func, pthread_mutex_t* pmtx, pthread_cond_t* pcond)
	: _tname(tname)
	, _func(func)
	, _pmtx(pmtx)
	, _pcond(pcond)
	{
    
    }
public:
	string _tname;			// 线程名
	func_t _func;			// 线程函数指针
	pthread_mutex_t* _pmtx; // 互斥锁指针
	pthread_cond_t* _pcond; // 条件变量指针
};
// 线程函数1
void tFunc1(const string& tname, pthread_mutex_t* pmtx, pthread_cond_t* pcond)
{
    
    
    while(!quit) 
    {
    
    
        pthread_mutex_lock(pmtx);		// 加锁
        if(!ready)						// 等待条件(失败就进入等待队列)
        	pthread_cond_wait(pcond, pmtx);	
        cout << tname << "正在运行任务A..." << endl;
        pthread_mutex_unlock(pmtx);		// 解锁
    }
    sleep(1);
}
// 线程函数2
void tFunc2(const string& tname, pthread_mutex_t* pmtx, pthread_cond_t* pcond)
{
    
    
    while(!quit) 
    {
    
    
        pthread_mutex_lock(pmtx);
         if(!ready)	pthread_cond_wait(pcond, pmtx);
        cout << tname << "正在运行任务B..." << endl;
        pthread_mutex_unlock(pmtx);
    }
    sleep(1);

}
// 线程函数3
void tFunc3(const string& tname, pthread_mutex_t* pmtx, pthread_cond_t* pcond)
{
    
    
    while(!quit) 
    {
    
    
        pthread_mutex_lock(pmtx);
         if(!ready)	pthread_cond_wait(pcond, pmtx);
        cout << tname << "正在运行任务C..." << endl;
        pthread_mutex_unlock(pmtx);
    }
    sleep(1);

}
void* Entry(void* args)
{
    
    
    ThreadData* td = (ThreadData*)args;
    td->_func(td->_tname, td->_pmtx, td->_pcond);
    delete td;
    return nullptr;
}

int main()
{
    
    
    pthread_mutex_t mtx;
    pthread_cond_t cond;
    pthread_mutex_init(&mtx, nullptr);
    pthread_cond_init(&cond, nullptr);

    pthread_t t[THREAD_NUM];
    func_t f[THREAD_NUM] = {
    
    tFunc1, tFunc2, tFunc3};
    for(int i = 0; i < THREAD_NUM; i++)
    {
    
    
        string tname = "thread[";
        tname += to_string(i + 1); tname += "]";
        ThreadData* td = new ThreadData(tname, f[i], &mtx, &cond);
        pthread_create(t + i, nullptr, Entry, (void*)td);
    }

    sleep(3);
    cout << "----线程控制逻辑开始----" << endl;
    int count = 3;
    while(count)
    {
    
    
    	if(count == 1) ready = true; 
        cout << "唤醒线程..." << count-- << endl;
        pthread_cond_broadcast(&cond);
        sleep(1);
    }
    cout << "----线程控制逻辑结束----" << endl;

    // pthread_cond_broadcast(&cond);
    quit = true;

    for(int i = 0; i < THREAD_NUM; i++)
    {
    
    
        pthread_join(t[i], nullptr);
        cout << "thread[" << t[i] << "]已退出..." << endl;
        sleep(1);
    }

    pthread_mutex_destroy(&mtx);
    pthread_cond_destroy(&cond);

    return 0;
}

正誤表、GIF のコードでは、pthread_cond_broadcast はループの外でも呼び出されていますが、結果には影響しません。ループ内で pthread_cond_broadcast を呼び出す目的は、リソース検出が成功した後に pthread_cond_wait を呼び出さないスレッドの動作を観察することです。

もちろん、pthread_cond_broadcast を pthread_cond_signal 実験に置き換えることもできます。結果は次のようになります。

画像-20230415000334683

おすすめ

転載: blog.csdn.net/m0_63312733/article/details/130164414