进程:有独立的 进程地址空间。有独立的pcb。 分配资源的最小单位。
线程(LWP):有独立的pcb。没有独立的进程地址空间。 最小单位的执行。
ps -Lf 进程号 查看进程的线程
多个进程共同存在时会抢夺cpu,cpu利用时钟中断机制分成多个时间片,若此时有A、B、C三个进程来抢夺的话,A、B、C是站在同等的地位上。若在A里面创建三个线程,在CPU眼里此时A有三个进程,能够抢夺到CPU的概率大大增加,执行速度增加。但若在A中再增加过多的线程,执行速度反而会下降。
线程内核实现原理
类Unix系统中,早期是没有“线程”概念,借助进程机制实现出了线程概念
类Unix系统中,进程与线程的区别与联系
从虚拟内存到物理内存,MMU做内存映射时,其实是要借助内核中的pcb的(pcb中包含MMU内存映射的地址)
在pcb中有一个指针,指向一片内存区域,这块区域称为页面,页面里面是一个个的指针,这里面的指针指向的是页表,页表里面也是一个个的指针,指向的是页目录,页目录里面是一个个内存单元,就是内存中的物理地址
当创建一个线程的时候,要复制一份pcb(pcb不完全一样,线程中的pcb也是独立的,但这个指针指向相同),所以线程也有一个指针指向页面,即这样的一个三级页表是一样的(内存地址相同)
而fork出的子进程,有独立的pcb,pcb的指针所指向的页面与父进程的不同,即三级页表不同(有独立的内存地址)
线程共享和非共享
共享 | 非共享 |
文件描述符表 | 线程ID |
每种信号的处理方式 | 处理器现场和栈指针(内核栈) |
当前工作目录 | 独立的栈空间(用户空间栈) |
用户ID和组ID | errno变量 |
内存地址空间 (./text./data ./rodataa ./bsss heap/共享库) | 信号屏蔽字 调度优先级 |
独享 栈空间(内核栈、用户栈)
共享 ./text./data ./rodataa ./bsss heap ---> 共享【全局变量】(errno)
全局变量父子进程间不共享,但线程间共享
线程是库函数实现,不如系统调用稳定
线程可用的情况下,优先选择线程
创建线程
LWP:线程号(标识线程身份交给CPU,CPU再划分时间轮片,分配程序执行时间),与线程ID概念不同
pthread_t pthread_self(void); 获取线程id。 线程id是在进程地址空间内部,用来标识线程身份的id号。
返回值:本线程id
int pthread_create(pthread_t *tid, const pthread_attr_t *attr, void *(*start_rountn)(void *), void *arg); 创建子线程。
参1:传出参数,表新创建的子线程 id
参2:线程属性。传NULL表使用默认属性。
参3:子线程回调函数。创建成功,pthread_create函数返回时,该函数会被自动调用。
参4:参3的参数。没有的话,传NULL
返回值:成功:0
失败:errno
下面是创建一个子线程去执行任务:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <pthread.h>
void sys_err(const char *str){
perror(str);
exit(1);
}
void *tfn(void *arg){ //子线程回调函数
printf("thread: pid = %d, tid = %lu\n", getpid(), pthread_self());
return NULL;
}
int main(int argc, char *argv[]){
//主线程
pthread_t tid;
printf("main: pid = %d, tid = %lu\n", getpid(), pthread_self());
int ret = pthread_create(&tid, NULL, tfn, NULL);
if (ret != 0) {
perror("pthread_create error");
}
return 0;
}
编译运行,结果如下:
可以看到,子线程的打印信息并未出现。原因在于,主线程执行完之后,就销毁了整个进程的地址空间,于是子线程就无法打印。简单粗暴的方法就是让主线程睡1秒,等子线程执行。
代码变化如下:
编译执行,如下:
循环创建多个子进程
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <pthread.h>
void sys_err(const char *str){
perror(str);
exit(1);
}
void *tfn(void *arg){
int i = (int)arg;
sleep(i);
printf("--I'm %dth thread: pid = %d, tid = %lu\n",i+1, getpid(), pthread_self());
return NULL;
}
int main(int argc, char *argv[]){
int i;
int ret;
pthread_t tid;
for(i=0;i<5;i++){
ret = pthread_create(&tid, NULL, tfn, (void *)i); //(void *)i因为回调函数传入是void *arg,要进行强制类型转换
if (ret != 0) {
sys_err("pthread_create error");
}
}
sleep(i);
printf("I'm main, pid = %d, tid = %lu\n", getpid(), pthread_self());
return 0;
}
编译运行,结果如下:
编译时会出现类型强转的警告,指针4字节转int的8字节,不过不存在精度损失,忽略就行。
如果将i取地址后再传入线程创建函数里,就是说
当前传的是:(void *)i
改成: (void *)&i
相应的,修改回调函数:int i = *((int *)arg)
运行代码,会出现如下结果:
如果多次运行都只有主线程的输出,将主线程等待时长从i改为大于6的数即可。因为子线程等待时间i是不定的,但都小于等于6秒,由于抢cpu时没抢过主线程,导致没有子线程的输出。
错误原因在于,子线程如果用引用传递i,会去读取主线程里的i值,而主线程里的i是动态变化的,不固定。所以,应该采用值传递,不用引用传递。
相关线程函数
pthread_exit函数
void pthread_exit(void *retval); 退出当前线程。
retval:退出值。 无退出值时,NULL
exit(); 退出当前进程。
return: 返回到调用者那里去。
pthread_exit(): 退出当前线程。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <pthread.h>
void sys_err(const char *str){
perror(str);
exit(1);
}
void *tfn(void *arg){
int i = (int)arg;
sleep(i);
if (i == 2) {
exit(0);
}//退出当前进程。
printf("--I'm %dth thread: pid = %d, tid = %lu\n",i+1, getpid(), pthread_self());
return NULL;
}
int main(int argc, char *argv[]){
int i;
int ret;
pthread_t tid;
for(i=0;i<5;i++){
ret = pthread_create(&tid, NULL, tfn, (void *)i); //(void *)i因为回调函数传入是void *arg,要进行强制类型转换
if (ret != 0) {
sys_err("pthread_create error");
}
}
sleep(i);
printf("I'm main, pid = %d, tid = %lu\n", getpid(), pthread_self());
return 0;
}
一个程序运行有一个主进程,在主进程中创建线程,exit()表示退出进程:
如果在回调函数里加一段代码:
if(i == 2)
exit(0);
看起来好像是退出了第三个子线程,然而运行时,发现后续的4,5也没了。这是因为,exit是退出进程。
一、修改一下,换成:
if(i == 2)
return NULL;
这样运行一下,发现后续线程不会凉凉,说明return是可以达到退出线程的目的。然而真正意义上,return是返回到函数调用者那里去,线程并没有退出。
二、再修改一下,再定义一个函数func,直接返回那种
void *func(void){
return NULL;
}
if(i == 2)
func();
运行,发现1,2,3,4,5线程都还在,说明没有达到退出目的。
三、再次修改:
void *func(void){
pthread_exit(NULL);
return NULL;
}
if(i == 2)
func();
编译运行,发现3没了,看起来很科学的样子。pthread_exit表示将当前线程退出。放在函数里,还是直接调用,都可以。
回到之前说的一个问题,由于主线程可能先于子线程结束,所以子线程的输出可能不会打印出来,当时是用主线程sleep等待子线程结束来解决的。现在就可以使用pthread_exit来解决了。方法就是将return 0替换为pthread_exit,只退出当前线程,不会对其他线程造成影响。
pthread_join函数
int pthread_join(pthread_t thread, void **retval); 阻塞 回收线程。
thread: 待回收的线程id
retval:传出参数。 回收的那个线程的退出值。
线程异常借助,值为 -1。
返回值:成功:0
失败:errno
注意这个函数是回收线程资源,不是终止线程(pthread_cancal、pthread_exit、exit())
与int pthread_create(pthread_t *tid, const pthread_attr_t *attr, void *(*start_rountn)(void *), void *arg);区分开,pthread_create的第一个参数是pthread_t *tid,是一个传出参数
void pthread_exit(void *retval)的退出值是void *retval,所以pthread_join要将线程,回收的第二个参数是void **retval,也是一个传出参数
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <pthread.h>
struct thrd {
int var;
char str[256];
};
void sys_err(const char *str)
{
perror(str);
exit(1);
}
void *tfn(void *arg) //回调函数
{
struct thrd *tval; //结构体指针
tval = malloc(sizeof(tval)); //使用tval接收malloc分配出的内存地址,但是sizeof(tval)是什么?
sizeof(tval),指的是tral变量占用内存大小,malloc(sizeof(tval))是分配一个tval大小的空间
tval->var = 100; //指针通过 -> 操作符可以访问成员
strcpy(tval->str, "hello thread");
return (void *)tval; //子线程的返回值,它会在pthread_join中作为传出参数,retval:传出参数。 回收的那个线程的退出值。
}
int main(int argc, char *argv[])
{
pthread_t tid;
struct thrd *retval;
int ret = pthread_create(&tid, NULL, tfn, NULL); //创建线程
if (ret != 0)
sys_err("pthread_create error");
//int pthread_join(pthread_t thread, void **retval);
ret = pthread_join(tid, (void **)&retval); //回收线程
if (ret != 0)
sys_err("pthread_join error");
printf("child thread exit with var= %d, str= %s\n", retval->var, retval->str);
pthread_exit(NULL); //退出主线程
}
编译运行,结果如下:
不可向上面这么写,因为这样的话,tval就是局部变量,返回就是无意义的了(应当使用指针作传出参数)
线程和进程类似,退出后不回收也会产生僵尸,pthread_join可以做回收工作
pthread_cancel函数
int pthread_cancel(pthread_t thread); 杀死一个线程。 需要到达取消点(保存点)
thread: 待杀死的线程id
返回值:成功:0
失败:errno
如果,子线程没有到达取消点, 那么 pthread_cancel 无效。
我们可以在程序中,手动添加一个取消点。使用 pthread_testcancel();
成功被 pthread_cancel() 杀死的线程,返回 -1.使用pthead_join 回收。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <pthread.h>
void *tfn(void *arg){
while (1) { //子线程每隔一秒打印一次,死循环
printf("thread: pid = %d, tid = %lu\n", getpid(), pthread_self());
sleep(1);
}
return NULL;
}
int main(int argc, char *argv[]){
pthread_t tid;
int ret = pthread_create(&tid, NULL, tfn, NULL); //创建线程
if (ret != 0) {
fprintf(stderr, "pthread_create error:%s\n", strerror(ret));
exit(1); //线程没有创建成功,退出进程
}
printf("main: pid = %d, tid = %lu\n", getpid(), pthread_self()); //(主线程)创建成功,打印当前进程和线程id
sleep(5);
ret = pthread_cancel(tid); // 终止子线程
if (ret != 0) {
fprintf(stderr, "pthread_cancel error:%s\n", strerror(ret));
exit(1);
}
while (1); //死循环,不让退出主线程
pthread_exit((void *)0); //退出主线程
}
编译运行,如下:
可以看到,主线程确实kill了子线程。
这里要注意一点,pthread_cancel工作的必要条件是进入内核,如果tfn真的奇葩到没有进入内核,则pthread_cancel不能杀死线程,此时需要手动设置取消点,就是pthread_testcancel()
线程分离pthread_detach
回收线程资源,主要是打开的文件、线程终止,会自动清理pcb,无需回收、占用的内存资源,以及线程有可能变成僵尸线程,导致无法在创建多余线程
设置线程分离` 线程终止,会自动清理pcb,无需回收
int pthread_detach(pthread_t thread); 设置线程分离
thread: 待分离的线程id
返回值:成功:0
失败:errno
1. #include <stdio.h>
2. #include <stdlib.h>
3. #include <string.h>
4. #include <unistd.h>
5. #include <errno.h>
6. #include <pthread.h>
7.
8.
9. void *tfn(void *arg)
10. {
11. printf("thread: pid = %d, tid = %lu\n", getpid(), pthread_self());
12.
13. return NULL;
14. }
15.
16. int main(int argc, char *argv[])
17. {
18. pthread_t tid;
19.
20. int ret = pthread_create(&tid, NULL, tfn, NULL);
21. if (ret != 0) {
22. fprintf(stderr, "pthread_create error: %s\n", strerror(ret));
23. exit(1);
24. }
25. ret = pthread_detach(tid); // 设置线程分离` 线程终止,会自动清理pcb,无需回收
26. if (ret != 0) {
27. fprintf(stderr, "pthread_detach error: %s\n", strerror(ret));
28. exit(1);
29. }
30.
31. sleep(1);
32.
33. ret = pthread_join(tid, NULL);
34. printf("join ret = %d\n", ret);
35. if (ret != 0) {
36. fprintf(stderr, "pthread_join error: %s\n", strerror(ret));
37. exit(1);
38. }
39.
40. printf("main: pid = %d, tid = %lu\n", getpid(), pthread_self());
41.
42. pthread_exit((void *)0);
43. }
编译运行,结果如下:
因为线程分离后,系统会自动回收资源,用pthread_join去回收已经被系统回收的线程,那个线程号就是无效参数
进程和线程函数对比
线程 | 进程 |
pthread_create() | fork() |
pthread_self() | getpid() |
pthread_exit() | exit(); |
pthread_join() | wait()/waitpid() |
pthread_cancel() | kill() |
pthread_detach() |
线程属性设置分离线程
设置分离属性。
pthread_attr_t attr 创建一个线程属性结构体变量(基本传null)
pthread_attr_init(&attr); 初始化线程属性
pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED); 设置线程属性为 分离态
pthread_create(&tid, &attr, tfn, NULL); 借助修改后的 设置线程属性 创建为分离态的新线程
pthread_attr_destroy(&attr); 销毁线程属性
调整线程状态,使线程创建出来就是分离态,代码如下:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <pthread.h>
void *tfn(void *arg)
{
printf("thread: pid = %d, tid = %lu\n", getpid(), pthread_self());
return NULL;
}
int main(int argc, char *argv[])
{
pthread_t tid;
pthread_attr_t attr;
int ret = pthread_attr_init(&attr);
if (ret != 0) {
fprintf(stderr, "attr_init error:%s\n", strerror(ret));
exit(1);
}
ret = pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED); // 设置线程属性为 分离属性
if (ret != 0) {
fprintf(stderr, "attr_setdetachstate error:%s\n", strerror(ret));
exit(1);
}
ret = pthread_create(&tid, &attr, tfn, NULL);
if (ret != 0) {
perror("pthread_create error");
}
ret = pthread_attr_destroy(&attr);
if (ret != 0) {
fprintf(stderr, "attr_destroy error:%s\n", strerror(ret));
exit(1);
}
ret = pthread_join(tid, NULL);
if (ret != 0) {
fprintf(stderr, "pthread_join error:%s\n", strerror(ret));
exit(1);
}
printf("main: pid = %d, tid = %lu\n", getpid(), pthread_self());
pthread_exit((void *)0);
}
编译运行,结果如下:
如图,pthread_join报错,说明线程已经自动回收,设置分离成功。
线程使用注意事项
第三个是因为线程间共享堆区
第五个,当有多个线程时,来了一个信号,这些线程是谁抢到这个信号谁就处理这个信号。若要注定某一个线程处理,每个线程有属于自己独立的屏蔽字,但其共享信号的处理方式,未决信号集共享
线程同步
协同步调,对公共区域数据按序访问。防止数据混乱,产生与时间有关的错误
数据混乱的原因:
- 资源共享(独享资源则不会)
- 调度随机(意味着数据访问会出现竞争)
- 线程间缺乏必要同步机制
互斥锁
锁的使用:
建议锁!对公共数据进行保护。所有线程【应该】在访问公共数据前先拿锁再访问。但,锁本身不具备强制性。
主要应用函数:
pthread_mutex_init 初始化函数
pthread_mutex_destory 销毁函数
pthread_mutex_lock 加锁函数
pthread_mutex_trylock 尝试拿锁函数
pthread_mutex_unlock 解锁函数
以上5个函数的返回值都是:成功返回0,失败返回错误号
pthread_mutex_t 类型,其本质是一个结构体。为简化理解,应用时可忽略其实现细节,简单当成整数看待
pthread_mutex_t mutex;变量mutex只有两种取值:0,1
使用mutex(互斥量、互斥锁)一般步骤:
pthread_mutex_t 类型。
1. pthread_mutex_t lock; 创建锁
2 pthread_mutex_init; 初始化 1
3. pthread_mutex_lock;加锁 1-- --> 0
4. 访问共享数据(stdout)
5. pthrad_mutext_unlock();解锁 0++ --> 1
6. pthead_mutex_destroy;销毁锁
int pthread_mutex_init(pthread_mutex_t *restrict mutex,
const pthread_mutexattr_t *restrict attr)
这里的restrict关键字,表示指针指向的内容只能通过这个指针进行修改
restrict关键字:
用来限定指针变量。被该关键字限定的指针变量所指向的内存操作,必须由本指针完成。
初始化互斥量:
pthread_mutex_t mutex;
1. pthread_mutex_init(&mutex, NULL); 动态初始化。
2. pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; 静态初始化。
使用锁实现互斥访问共享区:
#include <stdio.h>
#include <string.h>
#include <pthread.h>
#include <stdlib.h>
#include <unistd.h>
pthread_mutex_t mutex; // 定义一把互斥锁 (全局锁)都可访问
void *tfn(void *arg)
{
srand(time(NULL));
while (1) {
pthread_mutex_lock(&mutex); // 加锁
printf("hello ");
sleep(rand() % 3); // 模拟长时间操作共享资源,导致cpu易主,产生与时间有关的错误
printf("world\n");
pthread_mutex_unlock(&mutex); // 解锁
sleep(rand() % 3);
}
return NULL;
}
int main(void)
{
pthread_t tid;
srand(time(NULL));
int ret = pthread_mutex_init(&mutex, NULL); // 初始化互斥锁 ,创建线程前
if(ret != 0){
fprintf(stderr, "mutex init error:%s\n", strerror(ret));
exit(1);
}
pthread_create(&tid, NULL, tfn, NULL);
while (1) {
pthread_mutex_lock(&mutex); // 加锁
printf("HELLO ");
sleep(rand() % 3);
printf("WORLD\n");
pthread_mutex_unlock(&mutex); // 解锁
sleep(rand() % 3);
}
pthread_join(tid, NULL);
pthread_mutex_destory(&mutex); // 销毁互斥锁
return 0;
}
编译运行,结果如下:
可以看到,主线程和子线程在访问共享区时就没有交叉输出的情况了。
互斥锁使用技巧
注意事项:
尽量保证锁的粒度, 越小越好。(访问共享数据前,加锁。访问结束【立即】解锁。)
像这个就是访问结束后没有立即解锁,导致产生竞争CPU,这种情况下一般都是此线程能够竞争成功。
互斥锁,本质是结构体。 我们可以看成整数。 初值为 1。(pthread_mutex_init() 函数调用成功。)
加锁: --操作, 阻塞线程。
解锁: ++操作, 唤醒阻塞在锁上的线程。
try锁:尝试加锁,成功--。失败,返回。同时设置错误号 EBUSY
读写锁
读写锁:
锁只有一把。以读方式给数据加锁——读锁。以写方式给数据加锁——写锁。
读共享,写独占。
写锁优先级高。
相较于互斥量而言,当读线程多的时候,提高访问效率
pthread_rwlock_t rwlock;
pthread_rwlock_init(&rwlock, NULL);
pthread_rwlock_rdlock(&rwlock); try
pthread_rwlock_wrlock(&rwlock); try
pthread_rwlock_unlock(&rwlock);
pthread_rwlock_destroy(&rwlock);
以上函数都是成功返回0,失败返回错误号。
pthread_rwlock_t 类型 用于定义一个读写锁变量
pthread_rwlock_t rwlock
死锁
是使用锁不恰当导致的现象:
1. 对一个锁反复lock。
2. 两个线程,各自持有一把锁,请求另一把。
线程1拥有A锁,请求获得B锁;线程2拥有B锁,请求获得A锁
#include <stdio.h>
#include <unistd.h>
#include <pthread.h>
int counter; //全局资源
pthread_rwlock_t rwlock; //全局变量
void *th_write(void *arg)
{
int t;
int i = (int)arg;
while (1) {
t = counter; // 保存写之前的值
usleep(1000);
pthread_rwlock_wrlock(&rwlock);
printf("=======write %d: %lu: counter=%d ++counter=%d\n", i, pthread_self(), t, ++counter);
pthread_rwlock_unlock(&rwlock);
usleep(9000); // 给 r 锁提供机会
}
return NULL;
}
void *th_read(void *arg)
{
int i = (int)arg;
while (1) {
pthread_rwlock_rdlock(&rwlock);
printf("----------------------------read %d: %lu: %d\n", i, pthread_self(), counter);
pthread_rwlock_unlock(&rwlock);
usleep(2000); // 给写锁提供机会
}
return NULL;
}
int main(void)
{
int i;
pthread_t tid[8];
pthread_rwlock_init(&rwlock, NULL);
for (i = 0; i < 3; i++) //创建三个线程用来写
pthread_create(&tid[i], NULL, th_write, (void *)i);
for (i = 0; i < 5; i++) //创建五个线程用来读
pthread_create(&tid[i+3], NULL, th_read, (void *)i);
for (i = 0; i < 8; i++) //回收八个线程
pthread_join(tid[i], NULL);
pthread_rwlock_destroy(&rwlock); //释放读写琐
return 0;
}
编译运行,结果如下:
程序输出飞快,随便截个图,如上图。
由于读共享,写独占。写锁优先级高。前面5个read一定先于后面的write到达的,不然write会抢到锁先进行写操作。
静态初始化条件变量和互斥量
条件变量:
本身不是锁! 但是通常结合mutex锁来使用。
主要应用函数:
pthread_cond_t cond;
初始化条件变量:
1. pthread_cond_init(&cond, NULL); 动态初始化。
2. pthread_cond_t cond = PTHREAD_COND_INITIALIZER; 静态初始化
条件变量和相关函数wait
阻塞等待条件:
pthread_cond_wait(&cond, &mutex);
作用:
1) 阻塞等待条件变量满足
2) 解锁已经加锁成功的信号量 (相当于 pthread_mutex_unlock(&mutex),所以在调用这个函数之前要进行加锁操作),12两步为一个原子操作(12一步完成)
3) 当条件满足,函数返回时,解除阻塞并重新申请获取互斥锁。重新加锁信号量 (相当于, pthread_mutex_lock(&mutex);)
Pthread_cond_signl() 唤醒阻塞在条件变量中的一个线程
Pthread_cond_broadcast() 唤醒阻塞在条件变量中的多个线程
Pthread_cond_timewait() 等待一定时间,一旦超过这个时间就不再等待
条件变量的生产者消费者
1、在全局区域定义一个结构体,结构体是一个链表
2、在全局区域创建一个链表的头指针
3、在全局区域创建一个条件变量(大师傅是否生产出饼)、一个互斥量
4、消费者(线程)
定义一个节点(用以拿饼)
加锁(不让其他线程访问)
判断是否有饼,没饼的话解锁(让其他线程访问)、阻塞等待
5、生产者(线程)
创建链表节点(生产产品,随机生成1--1000)
加锁
把饼放到公共区
解锁
通知阻塞在条件变量上的线程
6、消费者(线程)
阻塞被唤醒,加锁
把饼从公共区拿出
解锁
Free掉这个饼
信号量概念及其相关操作函数
信号量:
应用于线程、进程间同步。
相当于 初始化值为 N 的互斥量。 N值,表示可以同时访问共享数据区的线程数。
函数:
sem_t sem; 定义类型。
int sem_init(sem_t *sem, int pshared, unsigned int value);
参数:
sem: 信号量
pshared: 0: 用于线程间同步
1: 用于进程间同步
value:N值。(指定同时访问的线程数)
sem_destroy();
sem_wait(); 一次调用,做一次-- 操作, 当信号量的值为 0 时,再次 -- 就会阻塞。 (对比 pthread_mutex_lock)
sem_post(); 一次调用,做一次++ 操作. 当信号量的值为 N 时, 再次 ++ 就会阻塞。(对比 pthread_mutex_unlock)