线程互斥——互斥锁与读写锁

时间:2021-11-16 18:35:19

一. 线程同步与互斥概念

    1. 线程同步

  • 是一个宏观概念,在微观上包含线程的相互排斥线程先后执行的约束问题;
  • 解决同步方式:条件变量和线程信号量;
    2. 线程互斥
  • 线程执行的相互排斥;
  • 解决互斥方式:互斥锁、读写锁和线程信号;
    3. 说明
        1)线程的同步与互斥主要是用于解决共享资源的安全性问题;         2)线程同步与线程互斥是不同的,线程同步是建立在线程互斥的基础上,要考虑线程先后执行的约束问题;     4. 示例—线程互斥
//account.h

#ifndef __ACCOUNT__H__
#define __ACCOUNT__H__
#include <pthread.h>

typedef struct
{
int code;
double balance;
}Account;

// 创建账户
extern Account* create_account(int code, double balance);
// 销毁账户
extern void destroy_account(Account *a);
// 取款
extern double withdraw(Account *a, double amt);
// 存款
extern double deposit(Account *a, double amt);
// 查看账户余额
extern double get_balance(Account *a);

#endif

//account.c

#include "account.h"
#include <malloc.h>
#include <assert.h>
#include <string.h>
#include <unistd.h>


// 创建账户
Account* create_account(int code, double balance)
{
Account *a = (Account*)malloc(sizeof(Account));
assert(a != NULL);
a->code = code;
a->balance = balance;

return a;
}

// 销毁账户
void destroy_account(Account *a)
{
assert(a != NULL);

free(a);
}

// 取款
double withdraw(Account *a, double amt)
{
assert(a != NULL);

double balance = a->balance;
if(balance <= 0 || balance < amt)
return 0;

sleep(1);//取款是个过程
balance -= amt;

a->balance = balance;

return amt;
}

// 存款
double deposit(Account *a, double amt)
{
assert(a != NULL);

if(amt < 0)
return 0.0;

double balance = a->balance;
sleep(1);
balance += amt;
a->balance = balance;

return amt;
}

// 查看账户余额
double get_balance(Account *a)
{
assert(a != NULL);

double balance = a->balance;

return balance;
}

//account_test.c

#include "account.h"
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

typedef struct
{
char name[20];
Account *account;
double amt;
}OperArg;

// 取款操作的线程运行函数
void *withdraw_fn(void *arg)
{
OperArg *oa = (OperArg*)arg;
double amt = withdraw(oa->account, oa->amt);
printf("%s(0x%lx) withdraw %f from account %d\n",
oa->name, pthread_self(),
amt, oa->account->code);

return (void*)0;
}

// 存款操作的线程运行函数
void *deposit_fn(void *arg)
{
OperArg *oa = (OperArg*)arg;
double amt = deposit(oa->account, oa->amt);
printf("%s(0x%lx) deposit %f from account %d\n",
oa->name, pthread_self(),
amt, oa->account->code);
return (void*)0;
}

//查看余额操作的线程运行函数
void *get_balance_fn(void *arg)
{
OperArg *oa = (OperArg*)arg;
double balance = get_balance(oa->account);
printf("%s(0x%lx) check balance %f from account %d\n",
oa->name, pthread_self(),
balance, oa->account->code);
return (void*)0;
}

// 检查银行账户的线程运行函数
void * check_fn(void *arg)
{
return (void*)0;
}

int main(void)
{
int err;
pthread_t boy, girl;
Account *a = create_account(100001, 10000);

OperArg o1, o2;// 对同一账户操作的两个用户
strcpy(o1.name, "boy");
o1.account = a;
o1.amt = 10000;

strcpy(o2.name, "girl");
o2.account = a;
o2.amt = 10000;

// 启动两个子线程(boy和girl)同时去操作同一个银行账户
// 线程运行函数:取款操作
if((err = pthread_create(&boy, NULL,
withdraw_fn, (void*)&o1)) != 0)
{
perror("pthread create error");
}
if((err = pthread_create(&girl, NULL,
withdraw_fn, (void*)&o2)) != 0)
{
perror("pthread create error");
}

//主控线程需阻塞
pthread_join(boy, NULL);
pthread_join(girl, NULL);

printf("account balance: %f\n", get_balance(a));
destroy_account(a);

return 0;
}

程序运行结果如下:
线程互斥——互斥锁与读写锁
说明:上述两个线程执行的操作是以取款操作为例,大家也可以试试其他的操作,每种操作的具体代码都给出了。 由上述结果可以看出,两个线程对同一账户(我们暂且认为是同一个账号的父子账户)进行操作,第一个线程执行取款操作,取款金额为10000,执行完毕后账户余额应该为0,第二个线程将不能取款才对,但是结果是第二个线程同样从账户中取出了10000,显然是不对的,原因就是:这里的账户是两个线程的共享资源,因此二者都 有权对其进行操作,但是对于这样的资源我们没有考虑互斥问题。也就是在一个线程对资源进行操作的时候,我们应该对该资源进行上锁,使其他线程不能操作,等第一个线程结束之后其他线程才能操作该资源。 因此,解决上述问题,我们需要用到互斥锁。
二. 互斥锁     互斥锁(mutex)是一种简单的加锁的方法来控制对共享资源的访问。在同一时刻只能有一个线程掌握某个互斥锁,拥有上锁状态的线程能够对共享资源进行访问。若其他线程希望上锁一个已经被上了互斥锁的资源,则该线程挂起,直到上锁的线程释放互斥锁为止。     互斥锁的数据类型:pthread_mutex_t.
    1. 互斥锁的创建和销毁     有两种方法创建互斥锁,静态方式和动态方式。     静态方式为:pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER,其中PTHREAD_MUTEX_INITIALIZER是POSIX标准定义的一个用来静态初始化互斥锁的宏,是一个结构常量。     动态方式是采用pthread_mutex_init()函数来初始化互斥锁。     
    #include <pthread.h>
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutex_attr_t *mutexattr);
int pthread_mutex_destroy(pthread_mutex_t *mutex);

    返回:成功返回0,否则返回错误编号;     参数:mutex:互斥锁                 mutexattr:互斥锁创建方式(互斥锁属性)                         a. PTHREAD_MUTEX_TIMED_NP:这是缺省值,也就是普通锁。当一个线程加锁以后,其余请求锁的线程将形成一                               个等待队列,并在解锁后按优先级获得锁。                         b. PTHREAD_MUTEX_RECURSIVE_NP:嵌套锁。允许同一个线程对同一个锁成功获得多次,并通过多次unlock解                                 锁。如果是不同线程请求,则在加锁线程解锁时重新竞争。                         c. PTHREAD_ERRORCHECK_INITIALIZER_NP:检错锁。如果同一个线程请求同一个锁,则返回EDEADLK,否则                               与PTHREAD_MUTEX_TIMED_NP类型动作相同。这样就保证当不允许多次加锁时不会出现最简单情况下的死锁。                         d. PTHREAD_MUTEX_ADAPTIVE_NP:适应锁,动作最简单的锁类型,仅等待解锁后重新竞争。
    2. 互斥锁的上锁和解锁
    #include <pthread.h>
int pthread_mutex_lock(pthread_mutex_t *mutex);
//功能:上锁,拿不到锁则阻塞;
int pthread_mutex_trylock(pthread_mutex_t *mutex);
//功能:上锁,拿不到锁返回出错消息;
int pthread_mutex_unlock(pthread_mutex_t *mutex);
//功能:释放锁;
//返回:成功返回0,出错返回出错码;

    3. 了解了互斥锁以后,下面我们对account这个案例进行修改,使得同一时刻只能有一个账户对账户资源进行操作。     分别对上面的三个文件进行修改。     1)对account.h的修改——仅在Account结构体中增加一个互斥锁变量
//account.h
//...
typedef struct
{
int code;
double balance;

// 定义一把互斥锁,用来对多线程操作的
// 银行账户(共享资源)进行枷锁(保护)的

/*
* 建议互斥锁用来锁定一个账户,和账户绑定在一起
* 尽量不设置成全局变量,否则可能出现一把锁
* 去锁几百个账户,导致并发性能降低。
*/
pthread_mutex_t mutex;
}Account;
//...
    2)对account.c的修改,这里我们仅以取款操作withdraw为例修改——就是在取款之前上锁,在取款之后释放锁。其他操作也是同样的修改。
//account.c
//...
// 创建账户
Account* create_account(int code, double balance)
{
Account *a = (Account*)malloc(sizeof(Account));
assert(a != NULL);
a->code = code;
a->balance = balance;
// 对互斥锁进行初始化
pthread_mutex_init(&a->mutex, NULL);

return a;
}

// 销毁账户
void destroy_account(Account *a)
{
assert(a != NULL);
// 销毁互斥锁
pthread_mutex_destroy(&a->mutex);

free(a);
}

// 取款
double withdraw(Account *a, double amt)
{
assert(a != NULL);
// 对共享资源(账户)加锁
pthread_mutex_lock(&a->mutex);

if(amt < 0 || amt > a->balance)
{
//释放互斥锁(异常)
pthread_mutex_unlock(&a->mutex);
return 0.0;
}
double balance = a->balance;

sleep(1);//取款是个过程
balance -= amt;
a->balance = balance;

// 释放互斥锁
pthread_mutex_unlock(&a->mutex);

return amt;
}
//...
    3)对于account_test.c不作修改。

    结果程序运行结果如下: 线程互斥——互斥锁与读写锁

    可以看出,当一个线程对Account这个共享资源进行操作时,另一个线程只能等待,直到第一个线程释放锁后,第二个线程才获得锁,对资源进行操作。
三. 互斥锁的属性和类型
1. 互斥锁进程共享属性操作
#include <pthreda.h>
int pthread_mutexattr_getpshared(const pthread_mutexattr_t *restrict attr, int *restrict pshared);
int pthread_mutexattr_setpshared(pthread_mutex_t *attr, int pshared);
返回:成功返回0,出错返回错误编号;
参数:attr:互斥锁属性;             pshared:进程共享属性                 1)PTHREAD_PROCESS_PRIVATE(默认情况)——锁只能用于一个进程内部的两个线程进行互斥;                 2)PTHREAD_PROCESS_SHARED——锁可以用于两个不同进程中的线程进行互斥;
2. 互斥锁类型操作
#include <pthread.h>
int pthread_mutexattr_gettype(const pthread_mutexattr_t *restrict attr, int *restrict type);
int pthread_mutexattr_settype(pthread_mutexattr_t *attr, int type);
返回:成功返回0,出错返回错误编号;
参数:attr:互斥锁属性;             type:互斥锁类型                1)标准互斥锁:PTHREAD_MUTEX_NORMAL                      第一次上锁成功,第二次上锁会阻塞;                2)递归互斥锁:PTHREAD_MUTEX_RECURSIVE                      第一次上锁成功,第二次上锁还是成功,内部计数;                3)检测互斥锁:PTHREAD_MUTEX_ERRIRCHECK                      第一次上锁成功,第二次上锁会出错;                4)默认互斥锁                      PTHREAD_MUTEX_DEFAULT(同标准互斥锁)
3. 示例——说明各个函数及参数的使用
//lock_type.c

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

int main(int argc, char *argv[])
{
if(argc < 2)
{
printf("-usage:%s [error|normal|recuesive]\n", argv[0]);
exit(1);
}

pthread_mutex_t mutex;

// 定义互斥锁属性
pthread_mutexattr_t mutexattr;
//初始化互斥锁属性
pthread_mutexattr_init(&mutexattr);

if(!strcmp(argv[1], "error"))
// 设置互斥锁类型
// 该类型:第一次上锁成功,第二次上锁失败,不阻塞
pthread_mutexattr_settype(&mutexattr,
PTHREAD_MUTEX_ERRORCHECK);
else if(!strcmp(argv[1], "normal"))
// 该类型:第一次上锁成功,第二次上锁阻塞
pthread_mutexattr_settype(&mutexattr,
PTHREAD_MUTEX_NORMAL);
else if(!strcmp(argv[1], "recursive"))
// 该类型:第一次上锁成功,后面的上锁也成功,计数
pthread_mutexattr_settype(&mutexattr,
PTHREAD_MUTEX_RECURSIVE);

pthread_mutex_init(&mutex, &mutexattr);

// 第一次上锁成功
if(pthread_mutex_lock(&mutex) != 0)
printf("first lock failure\n");
else
printf("first lock success\n");

//第二次上锁
if(pthread_mutex_lock(&mutex) != 0)
printf("second lock failure\n");
else
printf("second lock success\n");

pthread_mutex_unlock(&mutex);
pthread_mutex_unlock(&mutex);

pthread_mutexattr_destroy(&mutexattr);
pthread_mutex_destroy(&mutex);

return 0;
}
如果线程属性的类型是error,则结果如下:
线程互斥——互斥锁与读写锁
表示第一次上锁成功,第二次上锁失败(不阻塞)。

如果线程属性的类型是normal,则结果如下: 线程互斥——互斥锁与读写锁
表示第一次上锁成功,第二次上锁阻塞;
如果线程属性的类型是recursive,则结果如下: 线程互斥——互斥锁与读写锁
表示两次上锁都成功,内部进行计数。
四. 读写锁 引入读写锁是因为互斥锁有其弊端。 比如:在account案例中,如果仅仅是查看账户余额的操作(读),完全可以多个线程同时操作,但是互斥锁在第一个线程操作后就上锁了,后面的线程再执行此操作就会阻塞。
1. 读写锁
  • 线程使用互斥锁缺乏读写并发性;
  • 当读操作比较多,写操作比较少时,可使读写锁提高线程读并发性;
  • 读写锁数据类型  pthread_rwlock_t
#include <pthread.h>
int pthread_rwlock_init(pthread_rwlock_t *restrict rwlock, const pthread_rwlockattr_t *restrict attr);
int pthread_rwlock_destroy(pthread_rwlock_t *rwlock);
返回:成功返回0,出错返回错误编号;
参数:rwlock:读写锁            attr:读写锁属性
2. 读写锁的加锁和解锁
#include <pthread.h>
int pthread_rwlock_rdlock(pthread_rwlock_t *rdlock);
// 功能:加读锁
int pthread_rwlock_wrlock(pthread_rwlock_t *wrlock);
//功能:加写锁
int pthread_rwlock_unlock(pthread_rwlock_t *rwlock);
//功能:释放锁
//返回:成功返回0,出错返回错误编号

:在陈硕的《Linux多线程服务器端编程》一书中,他提到:不要用读写锁。下面我来转述一下。
读写锁看上去是个很美的抽象,它明确区分了read和write两种行为。初学者常干的一件事情是,一见到某个共享数据结构频繁读而很少写,就把mutex替换为rwlock。甚至首选rwlock来保护共享状态,这不见得是正确的。
  • 从正确性方面来说,一种典型的容易犯的错误就是在持有read lock的时候修改了共享数据。
  • 从性能方面来说,读写锁不见得比普通mutex更高效。无论如何reader lock加锁的开销不会比mutex lock小,因为它要更新当前reader的数目。如果临界区很小,锁竞争不激烈,那么mutex往往更快。
  • 通常reader lock是可重入的,writer lock是不可重入的。但是为了防止writer饥饿,writer lock通常会阻塞后来的reader lock,因此reader lock在重入的时候可能死锁。另外,在追求低延迟读取的场合也不适合用读写锁。