0x0b
如果逻辑控制流在时间上重叠,那么它们就是并发的。
使用应用级并发地应用程序称为并发程序。现代操作系统提供了三种基本的构造并发程序的方法:
- 进程。用这种方法,每个逻辑流都是一个进程,由内核来调度和维护。因为进程有独立的虚拟地址空间,想要和其他流通信,控制流必须使用某种显式的进程间通信(IPC)机制。
- I/O多路复用。在这种形式的并发编程中,应用程序在一个进程的上下文中显式地调度它们自己的逻辑流。逻辑流被模型化为状态机,数据到达文件描述符后,主程序显式地从一个状态转换到另一个状态。因为程序是一个单独的进程,所以所有的流都共享同一个地址空间。
- 线程。线程是运行在一个单一进程上下文中的逻辑流,由内核进行调度。
基于进程的并发编程
一个构造并发服务器的自然方法就是,在父进程中接收客户端连接请求,然后创建一个新的子进程来为每个新客户端提供服务。
假设有两个客户端和一个服务器,服务器正在监听一个监听描述符(如描述符3)上的连接请求。假设服务器接受了客户端1的连接请求,并返回一个已连接描述符(如描述符4)。在接收连接请求之后,服务器派生一个子进程,这个子进程获得服务器描述符表的完整副本。子进程关闭它的副本中的监听描述符3,而父进程关闭它的已连接描述符4的副本,因为不再需要这些描述符了。
因为父、子进程中的已连接描述符都指向同一个文件表表项,所以父进程关闭它的已连接描述符的副本是至关重要的。否则,将永不会释放已连接描述符4的文件表条目。
对于在父、子进程间共享状态信息,进程有一个非常清晰的模型:共享文件表,但是不共享用户地址空间。进程有独立的地址空间既是优点也是缺点。一个进程不可能不小心覆盖另一个进程的虚拟内存,但也使得进程共享状态信息变得更加困难。为了共享信息,它们必须使用显式的IPC机制。
基于I/O多路复用的并发编程
使用select函数,要求内核挂起进程,只有在一个或多个I/O时间发生后,才将控制返回给应用程序。
#include <sys/select.h> int select(int n, fd_set *fdset, NULL, NULL, NULL); FD_ZERO(fd_set *fdset); FD_CLR(int fd, fd_set *fdset); FD_SET(int fd, fd_set *fdset); FD_ISSET(int fd, fd_set *fdset);
select函数处理类型为fd_set的集合,也叫做描述符集合。
select函数有两个输入:一个称为读集合的描述符集合(fdset)和该读集合的基数(n)。select函数会一直阻塞,直到该读集合中至少有一个描述符准备好可以读。当且仅当一个从该描述符读取一个字节的请求不会阻塞时,描述符k就表示准备好可以读了。select有一个副作用,它修改参数fdset指向的fd_set,指明读集合的一个自己,称为准备好集合,这个集合是由读集合中准备好可以读了的描述符组成的。该函数返回的值指明了准备好集合的基数。由于这个副作用,必须在每次调用select时都更新读集合。
一旦select返回,就用FD_ISSET宏指令来确定哪个描述符准备好可以读了。
并发事件驱动服务器
I/O多路复用可以用做并发事件驱动程序的基础,在事件驱动程序中,某些事件会导致流向前推进。对于每个新的客户端k,基于I/O多路复用的并发服务器会创建一个新的状态机sk,并将它和已连接描述符dk联系起来。每个状态机sk都有一个状态(等待描述符dk准备好可读)、一个输入事件(描述符dk准备好可以读了)和一个转移(从描述符dk读一个文本行)。
事件驱动设计的一个优点是,它比基于进程的设计给了程序员更多的对程序行为的控制。且事件驱动服务器是运行在单一进程上下文中的,因此每个逻辑流都能访问该进程的全部地址空间,使得在流之间共享数据变得很容易。但是编码复杂。
基于线程的并发编程
Posix线程
void *thread(void *vargp); int main() { pthread_t tid; pthread_create(&tid, NULL, thread, NULL); pthread_join(tid, NULL); exit(0); } void *thread(void *vargp) { printf("Hello, world!\n"); return NULL; }
每个线程例程都以一个通用指针作为输入,并返回一个通用指针。如果想传递多个参数给线程例程,应该将参放到一个结构中,并传递一个指向该结构的指针。类似的,如果想要线程例程返回多个参数,可以返回一个指向一个结构的指针。
创建线程
#include <pthread.h> typedef void *(func)(void *); int pthread_create(pthread_t *tid, pthread_attr_t *attr, func *f, void *arg);
pthread_create函数创建一个新的线程,并带着一个输入变量arg,在新线程的上下文中运行线程例程f。能用attr参数改变新创建线程的默认属性。当pthread_create返回时,参数tid包含新创建线程的ID。新线程可以通过调用pthread_self函数来获得它自己的线程iD。
#include <pthread.h> pthread_t pthread_self(void);
终止线程
- 当顶层的线程例程返回时,线程会隐式地终止。
- 通过调用pthread_exit函数,线程会显式地终止。如果主线程调用pthread_exit,它会等待所有其他对等线程终止,然后再终止主线程和整个进程。
#include <pthread.h> void pthread_exit(void *thread_return);
- 某个对等线程调用Linux的exit函数,该函数终止进程以及所有与该进程相关的线程。
- 另一个对等线程通过以当前线程ID作为参数调用pthread_cancel函数来终止当前线程。
#include <pthread.h> int pthread_cancel(pthread_t tid);
回收已终止线程的资源
线程通过调用pthread_join函数等待其他线程终止。
#include <pthread.h> int pthread_join(pthread_t tid, void **thread_return);
pthread_join函数会阻塞,直到线程tid终止,将线程例程返回的通用指针赋值为thread_return指向的位置,然后回收已终止线程占用的所有内存资源。
分离线程
在任何一个时间点上,线程是可结合的或者是分离的。一个可结合的线程能够被其他线程收回和杀死。在被其他线程回收之前,它的内存资源是不释放的。相反,一个分离的线程是不能被其他线程回收或杀死的,它的内存资源在它终止时由系统自动释放。
默认情况下,线程被创建成可结合的。为了避免内存泄漏,每个可结合线程都应该要么被其他线程显式地收回,要么通过调用pthread_detach函数被分离。
#include <pthread.h> int pthread_detach(pthread_t tid);
初始化线程
pthread_once函数允许初始化与线程例程相关的状态。
#include <pthread.h> pthread_once_t once_control = PTHREAD_ONCE_INIT; int pthread_once(pthread_once_t *once_control, void (*init_routine)(void));
once_control变量是一个全局或者静态变量,总是被初始化为PTHREAD_ONCE_INIT。当第一次用参数once_control调用pthread_once时,它调用init_routine,这是一个没有输入参数、也不返回什么的函数。接下来的以once_control为参数的pthread_once调用不做任何事情。当需要动态初始化多个线程共享的全局变量,较为有用。
基于线程的并发服务器
当调用pthread_create时,如何将已连接描述符传递给对等线程。最明显的方法就是传递一个指向这个描述符的指针。
connfd = Accept(listenfd, (SA *)&clientaddr, &clientlen); Pthread_create(&tid, NULL, thread, &connfd); void *thread(void *vargp) { int connfd = *((int *)vargp); }
但是这样可能会出错,因为在对等线程的赋值语句和主线程accpte语句中引入了竞争。如果赋值语句在下一个accept之前完成,那么对等线程中的局部变量connfd就得到正确的描述符值。然而,如果赋值语句是在accept之后才完成的,那么对等线程中的局部变量connfd就得到了下一次连接的描述符值。
为了避免这种潜在的致命竞争,我们必须将accept返回的每个已连接描述符分配到它自己的动态分配的内存块:
int *connfdp; connfdp = Malloc(sizeof(int)); *connfdp = Accept(listenfd, (SA *)&clientaddr, &clientlen);
为了避免内存泄漏,既然不显式的收回线程,就必须分离每个线程,使得在它终止时它的内存资源能够被税后。
Pthread_detach(pthread_self()); Free(vargp);
多线程程序中的共享变量
#define N 2 void *thread(void *vargp); char **ptr; int main() { int i; pthread_t tid; char *msgs[N] = { "hello from foo", "hello from bar" }; ptr = msgs; for (i = 0; i < N; ++i) { Pthread_create(&tid, NULL, thread, (void *)i); } Pthread_exit(NULL); } void *thread(void *vargp) { int myid = (int)vargp; static int cnt = 0; printf("[%d]: %s (cnt = %d)\", myid, ptr[myid], ++cnt); return NULL; }
线程内存模型
一组并发线程运行在一个进程的上下文中。每个线程都有它自己独立的线程上下文,包括线程ID、栈、栈指针、程序计数器、条件码和通用目的寄存器值。每个线程和其他线程一起共享进程上下文的剩余部分。包括整个用户虚拟地址空间,是由只读文本(代码)、读/写数据、堆以及所有的共享库代码和数据区域组成的。线程也共享相同的打开文件的集合。
将变量映射到内存
多线程的C程序中的变量根据它们的存储类型被映射到虚拟内存:
- 全局变量。在运行时,虚拟内存的读/写区域只包含每个全局变量的一个实例,任何线程都可以引用。
- 本地自动变量。在运行时,每个线程的栈都包含它自己的所有本地自动变量的实例。即使多个线程执行同一个线程例程时也是如此。
- 本地静态变量。本地静态变量是定义在函数内部并有static属性的变量。和全局变量一样,虚拟内存的读/写区域只包含在程序中声明的每个本地静态变量的一个实例。’
共享变量
我们说一个变量v是共享的,当且仅当它的一个实例被一个以上的线程引用。变量cnt就是共享的,因为它只有一个运行时实例并且这个实例被两个对等线程引用。myid不是共享的,两个实例中每一个都只被一个线程引用。msgs这样的本地自动变量也能被共享。
用信号量同步线程
// badcnt.c void *thread(void *vargp); volatile long cnt = 0; int main(int argc, char **argv) { long niters; pthread_t tid1, tid2; if (argc != 2) { printf("usage: %s <niters>\n", argv[0]); exit(0); } niters = atoi(argv[1]); Pthread_create(&tid1, NULL, thread, &niters); Pthread_create(&tid2, NULL, thread, &niters); Pthread_join(tid1, NULL); Pthread_join(tid2, NULL); if (cnt != (2 * niters)) { printf("err"); } else { printf("ok"); } exit(0); } void *thread(void *vargp) { long i, niters = *((long *)vargp); for (i = 0; i < niters; ++i) cnt++; return NULL; }
为了保证线程化程序示例的正确执行(实际上任何共享全局数据结构的并发程序的正确执行),我们必须以某种方式同步线程。一个经典的方法是基于信号量的思想。
信号量
- P(s):如果s非零,那么P将s减一,并且立即返回。如果s为零,那么就挂起这个线程,直到s变为非零,而一个V操作会重启这个线程。重启之后,P操作将s减一,并将控制返回给调用者。
- V(s):V操作将s加1。如果有任何线程阻塞在P操作等待s变成非零,那么V操作会重启这些线程中的一个,然后该线程将s减1,完成它的P操作。
P和V的操作是原子的。
#inlclude <semaphore.h> int sem_init(sem_t *sem, 0, unsigned int value); int sem_wait(sem_t *s); /* P(s) */ int set_post(sem_t *s); /* V(s) */
sem_init函数将信号量sem初始化为value。每个信号量在使用前必须初始化。
volatile long cnt = 0; sem_t mutex; Sem_init(&mutex, 0, 1); for (int i = 0; i < niters; ++i) { P(&mutex); cnt++; V(&mutex); }
利用信号量来调度共享资源
生产者-消费者问题
因为插入和取出项目都涉及更新共享变量,所以必须保证对缓冲区的访问是互斥的。但是只保证互斥是不够的,还需要调度对缓冲区的访问,如果缓冲区是满的(没有空的槽位),那么生产者必须等待直到有一个槽位变为可用。与之相似,如果缓冲区是空的(没有可取用的项目),那么消费者必须等待直到有一个项目变为可用。
typedef struct { int *buf; int n; int front; int rear; sem_t mutex; sem_t slots; sem_t items; } sbuf_t;
void sbuf_init(sbuf_t *sp, int n) { sp->buf = Calloc(n, sizeof(int)); sp->n = n; sp->front = sp->rear = 0; Sem_init(&sp->mutex, 0, 1); Sem_init(&sp->slots, 0, n); Sem_init(&sp->items, 0, 0); } void sbuf_deinit(sbuf_t *sp) { Free(sp->buf); } void sbuf_insert(sbuf_t *sp, int item) { P(&sp->slots); P(&sp->mutex); sp->buf[(++sp->rear) % (sp->n)] = item; V(&sp->mutex); V(&sp->items); } int sbuf_remove(sbuf_t *sp) { int item; P(&sp->items); P(&sp->mutex); item = sp->buf[(++sp->front) % (sp->n)]; V(&sp->mutex); V(&sp->slots); return item; }
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· 没有Manus邀请码?试试免邀请码的MGX或者开源的OpenManus吧
· 【自荐】一款简洁、开源的在线白板工具 Drawnix
· 园子的第一款AI主题卫衣上架——"HELLO! HOW CAN I ASSIST YOU TODAY