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;
}
posted @   Pannnn  阅读(203)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· 没有Manus邀请码?试试免邀请码的MGX或者开源的OpenManus吧
· 【自荐】一款简洁、开源的在线白板工具 Drawnix
· 园子的第一款AI主题卫衣上架——"HELLO! HOW CAN I ASSIST YOU TODAY
-->
点击右上角即可分享
微信分享提示