那么初始化一个整型为 0,使用一万个线程,每个线程都对该整型加 1,最后结果不一定会是 10000。这是因为整型变量的赋值操作不是原子操作,也就是说它不是一个不可分割的操作,而是由多条指令组成的。例如,对一个整型变量执行 a++
操作,实际上包含了三个步骤:
在多线程环境中,如果没有同步机制,那么这三个步骤可能会被打断或重排,导致数据不一致或丢失。例如,假设有两个线程 A 和 B 同时对变量 a 执行 a++
操作,初始时 a 的值为 0,那么可能出现以下情况:
这样就出现了一个问题,虽然两个线程都对 a 加了 1,但是最后 a 的值只增加了 1。这就是多线程操作共享变量时可能出现的竞态条件(race condition),也就是多个线程同时访问和修改同一个数据时导致结果不正确或不可预测的情况。
要避免这个问题,有两种常见的方法:
头文件来支持原子操作。例如,可以使用 std::atomic
类型来声明一个原子整型变量,并使用 fetch_add
方法来对其进行原子加法操作。这样就可以保证每个线程都能正确地对该变量加 1,并返回旧值或新值。#include
#include
#include
std::atomic<int> a(0); // 声明一个原子整型变量,并初始化为 0
void add_one() {
// 对原子整型变量执行原子加法操作,并返回旧值
int old = a.fetch_add(1);
// 输出旧值和新值
std::cout << "Old value: " << old << ", new value: " << old + 1 << std::endl;
}
int main() {
// 创建一万个线程
std::thread threads[10000];
for (int i = 0; i < 10000; i++) {
threads[i] = std::thread(add_one);
}
// 等待所有线程结束
for (int i = 0; i < 10000; i++) {
threads[i].join();
}
// 输出最终结果
std::cout << "Final value: " << a << std::endl;
return 0;
}
头文件来支持互斥锁。例如,可以使用 std::mutex
类型来声明一个互斥锁,并使用 lock
和 unlock
方法来对其进行加锁和解锁操作。这样就可以保证每个线程在对共享变量加 1 时,不会被其他线程干扰。#include
#include
#include
int a = 0; // 声明一个普通整型变量,并初始化为 0
std::mutex m; // 声明一个互斥锁
void add_one() {
// 对互斥锁进行加锁
m.lock();
// 对普通整型变量执行加法操作
a++;
// 输出当前值
std::cout << "Current value: " << a << std::endl;
// 对互斥锁进行解锁
m.unlock();
}
int main() {
// 创建一万个线程
std::thread threads[10000];
for (int i = 0; i < 10000; i++) {
threads[i] = std::thread(add_one);
}
// 等待所有线程结束
for (int i = 0; i < 10000; i++) {
threads[i].join();
}
// 输出最终结果
std::cout << "Final value: " << a << std::endl;
return 0;
}
原子操作的优点是效率高,不需要额外的同步开销,但是它只能对简单的数据类型进行操作,而且不能保证内存顺序性(memory order),也就是说多个原子操作之间的执行顺序可能会被编译器或处理器重排,导致结果不符合预期。互斥锁的优点是灵活性高,可以对任意复杂的数据类型进行操作,而且可以保证内存顺序性,但是它需要额外的同步开销,而且可能导致死锁(deadlock),也就是说多个线程相互等待对方释放锁,导致程序无法继续执行。
除了原子操作和互斥锁之外,还有一些其他的方法可以避免多线程操作共享变量时出现的问题,例如:
使用条件变量(condition variable),也就是一种同步机制,它可以让一个线程等待另一个线程的通知,从而避免不必要的轮询或竞争。条件变量通常和互斥锁一起使用,以保证数据的一致性。C++ 提供了
头文件来支持条件变量。例如,可以使用 std::condition_variable
类型来声明一个条件变量,并使用 wait
和 notify_one
或 notify_all
方法来进行等待和通知操作。这样就可以实现生产者-消费者模式,也就是一个线程负责生产数据,另一个线程负责消费数据,两者之间通过条件变量进行协调。
#include
#include
#include
#include
#include
std::mutex m; // 声明一个互斥锁
std::condition_variable cv; // 声明一个条件变量
std::queue<int> q; // 声明一个队列
bool done = false; // 声明一个标志位
void producer() {
// 生产 10 个数据
for (int i = 0; i < 10; i++) {
// 对互斥锁进行加锁
std::unique_lock<std::mutex> lock(m);
// 向队列中插入数据
q.push(i);
// 输出生产的数据
std::cout << "Produced " << i << std::endl;
// 对互斥锁进行解锁
lock.unlock();
// 通知消费者
cv.notify_one();
}
// 设置标志位为 true
done = true;
// 通知消费者
cv.notify_one();
}
void consumer() {
while (true) {
// 对互斥锁进行加锁
std::unique_lock<std::mutex> lock(m);
// 等待生产者的通知或标志位为 true
cv.wait(lock, []{return !q.empty() || done;});
// 如果队列不为空,则取出数据并消费
if (!q.empty()) {
int x = q.front();
q.pop();
std::cout << "Consumed " << x << std::endl;
lock.unlock();
} else {
// 如果队列为空且标志位为 true,则退出循环
if (done) {
break;
}
lock.unlock();
}
}
}
int main() {
// 创建两个线程
std::thread t1(producer);
std::thread t2(consumer);
// 等待两个线程结束
t1.join();
t2.join();
return 0;
}
使用信号量(semaphore),也就是一种同步机制,它可以限制对共享资源的访问数量,从而避免过载或冲突。信号量有一个整数值,表示可用的资源数量,当一个线程想要访问资源时,它必须先获取信号量,如果信号量大于零,则信号量减一,并允许访问资源;如果信号量等于零,则线程必须等待其他线程释放信号量。当一个线程访问完资源后,它必须释放信号量,使信号量加一,并唤醒等待的线程。C++ 提供了
头文件来支持信号量。例如,可以使用 std::counting_semaphore
类型来声明一个计数信号量,并使用 acquire
和 release
方法来进行获取和释放操作。这样就可以实现多个线程同时访问有限数量的资源。
#include
#include
#include
std::counting_semaphore<3> sem(3); // 声明一个计数信号量,初始值为 3
void access_resource(int id) {
// 获取信号量
sem.acquire();
// 输出访问资源的线程 id
std::cout << "Thread " << id << " is accessing resource" << std::endl;
// 模拟访问资源的时间
std::this_thread::sleep_for(std::chrono::seconds(1));
// 输出释放资源的线程 id
std::cout << "Thread " << id << " is releasing resource" << std::endl;
// 释放信号量
sem.release();
}
int main() {
// 创建 10 个线程
std::thread threads[10];
for (int i = 0; i < 10; i++) {
threads[i] = std::thread(access_resource, i);
}
// 等待 10 个线程结束
for (int i = 0; i < 10; i++) {
threads[i].join();
}
return 0;
}
IO 多路复用:IO 多路复用是一种技术,它可以让一个进程或线程同时监视多个 IO 事件(如文件描述符、套接字等),并在其中一个或多个 IO 事件发生时,通知该进程或线程进行相应的处理。IO 多路复用可以提高 IO 效率,避免不必要的阻塞和轮询,适用于高并发的网络编程场景。
IO 多路复用的原理:IO 多路复用的原理是利用操作系统提供的一些系统调用(如 select, poll, epoll 等),将多个 IO 事件注册到一个事件集合中,然后让操作系统负责监视这些事件的状态变化,并在有事件发生时返回给用户程序。用户程序只需要调用一次系统调用,就可以处理多个 IO 事件,而不需要自己去轮询每个 IO 事件的状态,从而节省了 CPU 资源和时间。
IO 多路复用的优缺点:IO 多路复用的优点是它可以实现高效的 IO 处理,减少了进程或线程的切换开销,提高了并发性能。IO 多路复用的缺点是它需要额外的系统调用开销,而且不同的系统调用有各自的局限性和兼容性问题。
Linux 中常见的 IO 多路复用系统调用:Linux 中常见的 IO 多路复用系统调用有以下几种:
#include
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
其中,nfds 是要监视的文件描述符的数量,一般为最大文件描述符值加一;readfds, writefds, exceptfds 分别是指向读、写、异常文件描述符集合的指针,如果对某种类型不感兴趣,可以传入 NULL;timeout 是指定等待时间的指针,如果为 NULL,则表示无限等待;如果为零,则表示立即返回;否则表示等待指定的秒数和微秒数。select 的返回值是就绪文件描述符的数量,如果超时则返回零,如果出错则返回 -1,并设置 errno。
select 的优点是它可以跨平台使用,兼容性好;缺点是它只能监视 1024 个文件描述符(受 FD_SETSIZE 的限制),而且每次调用都需要将文件描述符集合从用户空间拷贝到内核空间,效率低。
#include
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
其中,fds 是指向一个 pollfd 结构体数组的指针,每个 pollfd 结构体包含了一个文件描述符和一个事件掩码(表示要监视哪些事件);nfds 是要监视的文件描述符的数量;timeout 是指定等待时间的毫秒数,如果为 -1,则表示无限等待;如果为零,则表示立即返回;否则表示等待指定的毫秒数。poll 的返回值是就绪文件描述符的数量,如果超时则返回零,如果出错则返回 -1,并设置 errno。
poll 的优点是它没有监视文件描述符的数量限制,而且不需要每次调用都重新设置文件描述符集合;缺点是它仍然需要将整个文件描述符数组从用户空间拷贝到内核空间,而且返回时需要遍历整个数组来找出就绪的文件描述符,效率低。
#include
int epoll_create(int size);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
其中,epoll_create 用于创建一个 epoll 实例,并返回一个文件描述符 epfd,size 参数已经被忽略,只是为了兼容旧版本的接口;epoll_ctl 用于向 epoll 实例中添加、修改或删除一个文件描述符,op 参数表示操作类型(EPOLL_CTL_ADD, EPOLL_CTL_MOD, EPOLL_CTL_DEL),fd 参数表示要操作的文件描述符,event 参数是指向一个 epoll_event 结构体的指针,该结构体包含了一个事件掩码和一个用户数据(可以是一个指针或一个整数);epoll_wait 用于等待 epoll 实例中的文件描述符就绪,并将就绪的文件描述符填充到 events 数组中,maxevents 参数表示 events 数组的大小,timeout 参数表示等待时间的毫秒数,如果为 -1,则表示无限等待;如果为零,则表示立即返回;否则表示等待指定的毫秒数。epoll_wait 的返回值是就绪文件描述符的数量,如果超时则返回零,如果出错则返回 -1,并设置 errno。
epoll 的优点是它使用了内核与用户空间共享内存的机制,避免了不必要的拷贝开销,而且它只返回就绪的文件描述符,不需要遍历整个集合,效率高;缺点是它只能在 Linux 上使用,而且对于某些特殊的文件描述符(如 pipe 的写端),它可能产生惊群效应(thundering herd),也就是说多个线程都被唤醒,但只有一个线程能够处理事件,其他线程又重新进入等待状态。
进程间通信:进程间通讯是指不同的进程之间如何传递和共享数据的技术,它可以实现进程之间的协作和同步,提高系统的并发性能和可靠性。Linux 操作系统中提供了多种进程间通讯的方法,主要有以下几种:
pipe
系统调用来创建一个管道,并返回两个文件描述符,分别表示管道的读端和写端。#include
int pipe(int pipefd[2]);
mkfifo
系统调用来创建一个命名管道,并返回一个文件描述符。#include
#include
int mkfifo(const char *pathname, mode_t mode);
msgget
,msgsnd
和 msgrcv
等系统调用来创建、发送和接收消息队列。#include
#include
#include
int msgget(key_t key, int msgflg);
int msgsnd(int msqid, const void *msgp, size_t msgsz, int msgflg);
ssize_t msgrcv(int msqid, void *msgp, size_t msgsz, long msgtyp, int msgflg);
进程崩溃:进程崩溃是指一个进程在运行过程中由于某些原因意外地终止,无法正常地完成其任务。进程崩溃通常会导致系统资源的浪费,用户数据的丢失,甚至系统的不稳定或死机。
进程崩溃的原因:进程崩溃的原因有很多,主要有以下几种:
进程崩溃的处理方法:进程崩溃的处理方法有以下几种:
套接字:套接字是一种通信机制,它可以在不同的进程或不同的主机之间进行数据交换,类似于文件描述符(file descriptor),它也是一个整数,表示一个打开的通信端点。套接字的头文件是
,它提供了一些函数和数据结构来创建和操作套接字。
套接字的类型:Linux 操作系统中支持以下几种类型的套接字:
SOCK_STREAM
。SOCK_DGRAM
。SOCK_RAW
。套接字的地址:每个套接字都有一个地址(address),用于标识通信的源和目的地。不同类型的套接字有不同格式的地址,但是它们都使用一个通用的数据结构来表示,即 sockaddr
结构体,它定义如下:
struct sockaddr {
sa_family_t sa_family; // 地址族
char sa_data[14]; // 地址信息
};
其中,sa_family 表示地址族(address family),也就是通信协议的类型,常见的地址族有 AF_INET
(IPv4 协议),AF_INET6
(IPv6 协议),AF_UNIX
(Unix 域协议)等;sa_data 表示地址信息,也就是具体的通信地址,它根据不同的地址族有不同的含义和格式。
为了方便使用不同地址族的地址信息,Linux 操作系统还提供了一些专门针对某种地址族的数据结构来表示地址,例如:
AF_INET
),使用 sockaddr_in
结构体来表示地址,它定义如下:struct sockaddr_in {
sa_family_t sin_family; // 地址族
in_port_t sin_port; // 端口号
struct in_addr sin_addr; // IP 地址
};
其中,sin_family 表示地址族,必须为 AF_INET
;sin_port 表示端口号,使用网络字节序(big-endian)表示;sin_addr 表示 IP 地址,使用一个 in_addr
结构体表示,它定义如下:
struct in_addr {
uint32_t s_addr; // IP 地址
};
其中,s_addr 表示 IP 地址,使用网络字节序表示。为了方便地将 IP 地址和端口号转换为字符串或数字,Linux 操作系统提供了一些函数,例如 inet_ntop
,inet_pton
,htons
,ntohs
等。
AF_INET6
),使用 sockaddr_in6
结构体来表示地址,它定义如下:struct sockaddr_in6 {
sa_family_t sin6_family; // 地址族
in_port_t sin6_port; // 端口号
uint32_t sin6_flowinfo; // 流信息
struct in6_addr sin6_addr; // IP 地址
uint32_t sin6_scope_id; // 作用域
};
其中,sin6_family 表示地址族,必须为 AF_INET6
;sin6_port 表示端口号,使用网络字节序表示;sin6_flowinfo 表示流信息,用于区分同一主机上的不同流;sin6_addr 表示 IP 地址,使用一个 in6_addr
结构体表示,它定义如下:
struct in6_addr {
unsigned char s6_addr[16]; // IP 地址
};
其中,s6_addr 表示 IP 地址,使用网络字节序表示。为了方便地将 IP 地址和端口号转换为字符串或数字,Linux 操作系统提供了一些函数,例如 inet_ntop
,inet_pton
,htons
,ntohs
等。
AF_UNIX
),使用 sockaddr_un
结构体来表示地址,它定义如下:#define UNIX_PATH_MAX 108
struct sockaddr_un {
sa_family_t sun_family; // 地址族
char sun_path[UNIX_PATH_MAX]; // 路径名
};
其中,sun_family 表示地址族,必须为 AF_UNIX
;sun_path 表示路径名,也就是 Unix 域套接字所对应的文件名。
套接字的操作:Linux 操作系统中提供了一些函数来创建和操作套接字,主要有以下几种:
#include
#include
int socket(int domain, int type, int protocol);
其中,domain 参数表示地址族的类型,可以是 AF_INET
, AF_INET6
, AF_UNIX
等;type 参数表示套接字的类型,可以是 SOCK_STREAM
, SOCK_DGRAM
, SOCK_RAW
等;protocol 参数表示具体的协议类型,通常为 0,表示使用默认的协议。socket 函数的返回值是一个文件描述符,如果出错则返回 -1,并设置 errno。
#include
#include
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
其中,sockfd 参数是由 socket 函数返回的文件描述符;addr 参数是指向一个 sockaddr 结构体或其子结构体(如 sockaddr_in, sockaddr_in6, sockaddr_un 等)的指针;addrlen 参数是该结构体的大小。bind 函数的返回值是 0,表示成功;如果出错则返回 -1,并设置 errno。
#include
#include
int listen(int sockfd, int backlog);
其中,sockfd 参数是由 socket 函数返回的文件描述符;backlog 参数表示等待连接队列(pending connection queue)的最大长度,也就是说该套接字可以等待的最大连接数。listen 函数的返回值是 0,表示成功;如果出错则返回 -1,并设置 errno。
accept:accept 函数用于接受一个连接请求,并返回一个新的文件描述符,该文件描述符用于与客户端进行通信,它的原型如下:
#include
#include
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
其中,sockfd 参数是由 listen 函数创建的套接字的文件描述符;addr 参数是一个指向 sockaddr 结构体的指针,用于存储客户端的地址信息;addrlen 参数是一个指向整数的指针,用于表示 addr 结构体的大小。accept 函数的返回值是一个新的文件描述符,用于与客户端进行通信;如果出错则返回 -1,并设置 errno。
#include
#include
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
其中,sockfd 参数是由 socket 函数创建的套接字的文件描述符;addr 参数是一个指向 sockaddr 结构体的指针,用于指定目标地址;addrlen 参数是 addr 结构体的大小。connect 函数的返回值是 0,表示成功;如果出错则返回 -1,并设置 errno。
#include
#include
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
其中,sockfd 参数是已连接的套接字的文件描述符;buf 参数是一个指向数据缓冲区的指针;len 参数表示数据的长度;flags 参数用于指定发送或接收数据的选项。send 和 recv 函数的返回值是实际发送或接收的数据长度,如果出错则返回 -1,并设置 errno。
#include
int close(int sockfd);
其中,sockfd 参数是要关闭的套接字的文件描述符。close 函数的返回值是 0,表示成功;如果出错则返回 -1,并设置 errno。
这些是套接字在 Linux 操作系统中的基本操作和数据结构,可以用于实现各种网络通信应用。根据不同的需求和场景,可以选择合适的套接字类型、地址族和操作来构建应用程序。同时,还需要考虑网络安全性、性能优化等方面的问题,以确保应用程序的稳定性和可靠性。