0%

Linux Socket编程

Socket

  • 套接字(socket)是 Linux 下的一种进程间通信机制(socket IPC),在前面的内容中已经给大家提到过, 使用 socket IPC 可以使得在不同主机上的应用程序之间进行通信(网络通信),当然也可以是同一台主机上 的不同应用程序。socket IPC 通常使用客户端<—>服务器这种模式完成通信,多个客户端可以同时连接到服务器中,与服务器之间完成数据交互。
  • 当前网络中的主流程序设计都是使用 socket 进行编程的,因为它简单易用,它还是一个标准(BSD socket),能在不同平台很方便移植,比如你的一个应用程序是基于 socket 接口编写的,那么它可以移植到 任何实现 BSD socket 标准的平台,譬如 LwIP,它兼容 BSD Socket;又譬如 Windows,它也实现了一套基于 socket 的套接字接口,更甚至在国产操作系统中,如 RT-Thread,它也实现了 BSD socket 标准的 socket 接 口。

Socket编程

头文件

#include <sys/types.h> /* See NOTES */
#include <sys/socket.h>

socket()函数

  • socket()函数类似于 open()函数,它用于创建一个网络通信端点(打开一个网络通信),如果成功则返回 一个网络文件描述符,通常把这个文件描述符称为 socket 描述符(socket descriptor),这个 socket 描述符跟 文件描述符一样,后续的操作都有用到它,把它作为参数,通过它来进行一些读写操作。
#include <sys/types.h> /* See NOTES */
#include <sys/socket.h>
int socket(int domain, int type, int protocol);
  • 参数 domain 用于指定一个通信域;这将选择将用于通信的协议族。可选的协议族如下表所示

    • image-20220130165700332

    • image-20220130165812152

    • 对于 TCP/IP 协议来说,通常选择 AF_INET 就可以了,当然如果你的 IP 协议的版本支持 IPv6,那么可 以选择 AF_INET6

  • 参数 type 指定套接字的类型,当前支持的类型有

    • image-20220130165859271
  • 参数 protocol 通常设置为 0,表示为给定的通信域和套接字类型选择默认协议。当对同一域和套接字类 型支持多个协议时,可以使用 protocol 参数选择一个特定协议。在 AF_INET 通信域中,套接字类型为 SOCK_STREAM 的默认协议是传输控制协议(Transmission Control Protocol,TCP 协议)。在 AF_INET 通 信域中,套接字类型为 SOCK_DGRAM 的默认协议时 UDP。

  • 调用 socket()与调用 open()函数很类似,调用成功情况下,均会返回用于文件 I/O 的文件描述符,只不 过对于 socket()来说,其返回的文件描述符一般称为 socket 描述符。当不再需要该文件描述符时,可调用 close()函数来关闭套接字,释放相应的资源。

int socket_fd = socket(AF_INET, SOCK_STREAM, 0);//打开套接字
if (0 > socket_fd) {
perror("socket error");
exit(-1);
}
......
......
close(socket_fd); //关闭套接字

bind()函数

int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
  • bind()函数用于将一个 IP 地址或端口号与一个套接字进行绑定(将套接字与地址进行关联)。
  • 一般来讲,会将一个服务器的 套接字绑定到一个众所周知的地址—即一个固定的与服务器进行通信的客户端应用程序提前就知道的地址 (注意这里说的地址包括 IP 地址和端口号)。因为对于客户端来说,它与服务器进行通信,首先需要知道 服务器的 IP 地址以及对应的端口号,所以通常服务器的 IP 地址以及端口号都是众所周知的。
  • 参数 addr 是一个指针,指向一个 struct sockaddr 类型变量
struct sockaddr {
sa_family_t sa_family;
char sa_data[14];
}
  • 第二个成员 sa_data 是一个 char 类型数组,一共 14 个字节,在这 14 个字节中就包括了 IP 地址、端口 号等信息,这个结构对用户并不友好,它把这些信息都封装在了 sa_data 数组中,这样使得用户是无法对 sa_data 数组进行赋值。事实上,这是一个通用的 socket 地址结构体。
  • 一般我们在使用的时候都会使用 struct sockaddr_in 结构体,sockaddr_in 和 sockaddr 是并列的结构占 用的空间是一样的),指向 sockaddr_in 的结构体的指针也可以指向 sockadd 的结构体,并代替它,而且 sockaddr_in 结构对用户将更加友好,在使用的时候进行类型转换就可以了。
struct sockaddr_in {
sa_family_t sin_family; /* 协议族 */
in_port_t sin_port; /* 端口号 */
struct in_addr sin_addr; /* IP 地址 */
unsigned char sin_zero[8];
};

listen()函数

  • listen()函数只能在服务器进程中使用,让服务器进程进入监听状态,等待客户端的连接请求,listen()函 数在一般在 bind()函数之后调用,在 accept()函数之前调用
int listen(int sockfd, int backlog);
  • 参数 backlog 用来描述 sockfd 的等待连接队列能够达到的最大值。在服务器进程正处理客户端连接请 求的时候,可能还存在其它的客户端请求建立连接,因为 TCP 连接是一个过程,由于同时尝试连接的用户 过多,使得服务器进程无法快速地完成所有的连接请求.因此内核会在自己的进程空间里维护一个队列,这些连接请求就会被放入一个队 列中,服务器进程会按照先来后到的顺序去处理这些连接请求,这样的一个队列内核不可能让其任意大,所 以必须有一个大小的上限,这个 backlog 参数告诉内核使用这个数值作为队列的上限。而当一个客户端的连接请求到达并且该队列为满时,客户端可能会收到一个表示连接失败的错误,本次请求会被丢弃不作处理。

accept()函数

int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
  • 服务器流程

    • 调用 socket()函数打开套接字;
    • 调用 bind()函数将套接字与一个端口号以及 IP 地址进行绑定;
    • 调用 listen()函数让服务器进程进入监听状态,监听客户端的连接请求;
    • 调用 accept()函数处理到来的连接请求。
  • accept()函数通常只用于服务器应用程序中,如果调用 accept()函数时,并没有客户端请求连接(等待连 接队列中也没有等待连接的请求),此时 accept()会进入阻塞状态,直到有客户端连接请求到达为止。

  • 当有 客户端连接请求到达时,accept()函数与远程客户端之间建立连接,accept()函数返回一个新的套接字。这个 套接字与 socket()函数返回的套接字并不同,socket()函数返回的是服务器的套接字(以服务器为例)

  • accept()函数返回的套接字连接到调用 connect()的客户端,服务器通过该套接字与客户端进行数据交互,譬 如向客户端发送数据、或从客户端接收数据。

  • 参数 addr 是一个传出参数,参数 addr 用来返回已连接的客户端的 IP 地址与端口号等这些信息。参数 addrlen 应设置为 addr 所指向的对象的字节长度,如果我们对客户端的 IP 地址与端口号这些信息不感兴趣, 可以把 arrd 和 addrlen 均置为空指针 NULL

connect()函数

int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
  • 该函数用于客户端应用程序中,客户端调用 connect()函数将套接字 sockfd 与远程服务器进行连接,参 数 addr 指定了待连接的服务器的 IP 地址以及端口号等信息,参数 addrlen 指定了 addr 指向的 struct sockaddr 对象的字节大小。
  • 客户端通过 connect()函数请求与服务器建立连接,对于 TCP 连接来说,调用该函数将发生 TCP 连接的 握手过程,并最终建立一个 TCP 连接,而对于 UDP 协议来说,调用这个函数只是在 sockfd 中记录服务器 IP 地址与端口号,而不发送任何数据。
  • 函数调用成功则返回 0,失败返回-1,并设置 errno 以指示错误原因。

发送和接收函数

read()函数

  • 通过 read()函数从一个文件描述符中读取指定字节大小的数据并放入到指 定的缓冲区中,read()调用成功将返回读取到的字节数,此返回值受文件剩余字节数限制,当返回值小于指 定的字节数时并不意味着错误;这可能是因为当前可读取的字节数小于指定的字节数(比如已经接近文件 结尾,或者正在从管道或者终端读取数据,或者 read()函数被信号中断等),出错返回-1 并设置 errno,如果 在调 read 之前已到达文件末尾,则这次 read 返回 0。
  • 套接字描述符也是文件描述符,所以使用 read()函数读取网络数据时,read()函数的参数 fd 就是对应的 套接字描述符

recv()函数

ssize_t recv(int sockfd, void *buf, size_t len, int flags);
  • 不论是客户端还是服务器都可以通过 revc()函数读取网络数据,它与 read()函数的功能是相似的。参数 sockfd 指定套接字描述符,参数 buf 指向了一个数据接收缓冲区,参数 len 指定了读取数据的字节大小,参 数 flags 可以指定一些标志用于控制如何接收数据。

  • 函数 recv()与 read()很相似,但是 recv()可以通过指定 flags 标志来控制如何接收数据,这些标志如下所 示:

    • image-20220130174245872

    • 通常一般我们将 flags 参数设置为 0,当然,你可以根据自己的需求设置该参数。

    • 当指定 MSG_PEEK 标志时,可以查看下一个要读取的数据但不真正取走它,当再次调用 read 或 recv 函数时,会返回刚才查看的数据。

    • 对于 SOCK_STREAM 类型套接字,接收的数据可以比指定的字节大小少。MSG_WAITALL 标志会阻 止这种行为,直到所请求的数据全部返回,recv 函数才会返回。对于 SOCK_DGRAM 和 SOCK_SEQPACKET 套接字,MSG_WAITALL 标志并不会改变什么行为,因为这些基于报文的套接字类型一次读取就返回整个 报文。

    • 如果发送者已经调用 shutdown 来结束传输,或者网络协议支持按默认的顺序关闭并且发送端已经关闭, 那么当所有的数据接收完毕后,recv 会返回 0。

write函数

  • 通过 write()函数可以向套接字描述符中写入数据,函数调用成功返回写入的字节数,失败返回-1,并设 置 errno 变量。

send()函数

ssize_t send(int sockfd, const void *buf, size_t len, int flags);
  • send 和 write 很相似,但是 send 可以通过参数 flags 指定一些标志,来改变处理传输数据的方式。

    • image-20220130174442209
  • 即使 send()成功返回,也并不表示连接的另一端的进程就一定接收了数据,我们所能保证的只是当 send 成功返回时,数据已经被无错误的发送到网络驱动程序上

close()关闭套接字

  • 当不再需要套接字描述符时,可调用 close()函数来关闭套接字,释放相应的资源。

IP 地址格式转换函数

  • 对于人来说,我们更容易阅读的是点分十进制的 IP 地址,譬如 192.168.1.110、192.168.1.50,这其实是 一种字符串的形式,但是计算机所需要理解的是二进制形式的 IP 地址,所以我们就需要在点分十进制字符 串和二进制地址之间进行转换。
  • 点分十进制字符串和二进制地址之间的转换函数主要有:inet_aton、inet_addr、inet_ntoa、inet_ntop、 inet_pton 这五个,在我们的应用程序中使用它们需要包含头文件、以及<netinet/in.h>。

inet_aton、inet_addr、inet_ntoa 函数

  • 这些函数可将一个 IP 地址在点分十进制表示形式和二进制表示形式之间进行转换,这些函数已经废弃 了,基本不用这些函数了,但是在一些旧的代码中可能还会看到这些函数。完成此类转换工作我们应该使用 下面介绍的这些函数。

inet_ntop、inet_pton 函数

  • inet_ntop()、inet_pton()与 inet_ntoa()、inet_aton()类似,但它们还支持 IPv6 地址。它们将二进制 Ipv4 或 Ipv6 地址转换成以点分十进制表示的字符串形式,或将点分十进制表示的字符串形式转换成二进制 Ipv4 或 Ipv6 地址。使用这两个函数只需包含头文件即可!
  • inet_pton()函数
int inet_pton(int af, const char *src, void *dst);
  • inet_pton()函数将点分十进制表示的字符串形式转换成二进制 Ipv4 或 Ipv6 地址。

  • inet_pton()函数将点分十进制表示的字符串形式转换成二进制 Ipv4 或 Ipv6 地址。 将字符串 src 转换为二进制地址,参数 af 必须是 AF_INET 或 AF_INET6,AF_INET 表示待转换的 Ipv4 地址,AF_INET6 表示待转换的是 Ipv6 地址;并将转换后得到的地址存放在参数 dst 所指向的对象中,如果 参数 af 被指定为 AF_INET,则参数 dst 所指对象应该是一个 struct in_addr 结构体的对象;如果参数 af 被指 定为 AF_INET6,则参数 dst 所指对象应该是一个 struct in6_addr 结构体的对象。

  • inet_pton()转换成功返回 1(已成功转换)。如果 src 不包含表示指定地址族中有效网络地址的字符串, 则返回 0。如果 af 不包含有效的地址族,则返回-1 并将 errno 设置为 EAFNOSUPPORT。

  • inet_ntop()函数

  • inet_ntop()函数执行与 inet_pton()相反的操作

const char *inet_ntop(int af, const void *src, char *dst, socklen_t size);
  • 参数 af 与 inet_pton()函数的 af 参数意义相同。
  • 参数 src 应指向一个 struct in_addr 结构体对象或 struct in6_addr 结构体对象,依据参数 af 而定。函数 inet_ntop()会将参数 src 指向的二进制 IP 地址转换为点分十进制形式的字符串,并将字符串存放在参数 dst所指的缓冲区中
  • 参数 size 指定了该缓冲区的大小。 inet_ntop()在成功时会返回 dst 指针。如果 size 的值太小了,那么将会返回 NULL 并将 errno 设置为 ENOSPC。

使用例

  • 服务端
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#define SERVER_PORT 8888 //端口号不能发生冲突,不常用的端口号通常大于 5000
int main(void)
{
struct sockaddr_in server_addr = {0};
struct sockaddr_in client_addr = {0};
char ip_str[20] = {0};
int sockfd, connfd;
int addrlen = sizeof(client_addr);
char recvbuf[512];
int ret;
/* 打开套接字,得到套接字描述符 */
sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (0 > sockfd) {
perror("socket error");
exit(EXIT_FAILURE);
}
/* 将套接字与指定端口号进行绑定 */
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
server_addr.sin_port = htons(SERVER_PORT);
ret = bind(sockfd, (struct sockaddr *)&server_addr, sizeof(server_addr));
if (0 > ret) {
perror("bind error");
close(sockfd);
exit(EXIT_FAILURE);
}
/* 使服务器进入监听状态 */
ret = listen(sockfd, 50);
if (0 > ret) {
perror("listen error");
close(sockfd);
exit(EXIT_FAILURE);
}
/* 阻塞等待客户端连接 */
connfd = accept(sockfd, (struct sockaddr *)&client_addr, &addrlen);
if (0 > connfd) {
perror("accept error");
close(sockfd);
exit(EXIT_FAILURE);
}
printf("有客户端接入...\n");
inet_ntop(AF_INET, &client_addr.sin_addr.s_addr, ip_str, sizeof(ip_str));
printf("客户端主机的 IP 地址: %s\n", ip_str);
printf("客户端进程的端口号: %d\n", client_addr.sin_port);
/* 接收客户端发送过来的数据 */
for ( ; ; ) {
// 接收缓冲区清零
memset(recvbuf, 0x0, sizeof(recvbuf));
// 读数据
ret = recv(connfd, recvbuf, sizeof(recvbuf), 0);
if(0 >= ret) {
perror("recv error");
close(connfd);
break;
}
// 将读取到的数据以字符串形式打印出来
printf("from client: %s\n", recvbuf);
// 如果读取到"exit"则关闭套接字退出程序
if (0 == strncmp("exit", recvbuf, 4)) {
printf("server exit...\n");
close(connfd);
break;
}
}
/* 关闭套接字 */
close(sockfd);
exit(EXIT_SUCCESS);
}

  • 客户端
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#define SERVER_PORT 8888 //服务器的端口号
#define SERVER_IP "192.168.1.150" //服务器的 IP 地址
int main(void)
{
struct sockaddr_in server_addr = {0};
char buf[512];
int sockfd;
int ret;
/* 打开套接字,得到套接字描述符 */
sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (0 > sockfd) {
perror("socket error");
exit(EXIT_FAILURE);
}
/* 调用 connect 连接远端服务器 */
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(SERVER_PORT); //端口号
inet_pton(AF_INET, SERVER_IP, &server_addr.sin_addr);//IP 地址
ret = connect(sockfd, (struct sockaddr *)&server_addr, sizeof(server_addr));
if (0 > ret) {
perror("connect error");
close(sockfd);
exit(EXIT_FAILURE);
}
printf("服务器连接成功...\n\n");
/* 向服务器发送数据 */
for ( ; ; ) {
// 清理缓冲区
memset(buf, 0x0, sizeof(buf));
// 接收用户输入的字符串数据
printf("Please enter a string: ");
fgets(buf, sizeof(buf), stdin);
// 将用户输入的数据发送给服务器
ret = send(sockfd, buf, strlen(buf), 0);
if(0 > ret){
perror("send error");
break;
}
//输入了"exit",退出循环
if(0 == strncmp(buf, "exit", 4))
break;
}
close(sockfd);
exit(EXIT_SUCCESS);
}

Linux文件锁

  • 对于有些应用程序,进程有时需要确保只有它自己能够对某一文件进行 I/O 操作,在这段时间内不允许 其它进程对该文件进行 I/O 操作。为了向进程提供这种功能,Linux 系统提供了文件锁机制。
  • 譬如进程对文件进行 I/O 操作时,首先对文件进行上锁,将其锁住,然后再进行读写操作;只要进程没有对 文件进行解锁,那么其它的进程将无法对其进行操作;这样就可以保证,文件被锁住期间,只有它(该进程) 可以对其进行读写操作。

文件锁的分类

建议性锁

  • 建议性锁本质上是一种协议,程序访问文件之前,先对文件上锁,上锁成功之后再访问文件,这是建议 性锁的一种用法;但是如果你的程序不管三七二十一,在没有对文件上锁的情况下直接访问文件,也是可以 访问的,并非无法访问文件;如果是这样,那么建议性锁就没有起到任何作用,如果要使得建议性锁起作用, 那么大家就要遵守协议,访问文件之前先对文件上锁。这就好比交通信号灯,规定红灯不能通行,绿灯才可 以通行,但如果你非要在红灯的时候通行,谁也拦不住你,那么后果将会导致发生交通事故;所以必须要大 家共同遵守交通规则,交通信号灯才能起到作用。

强制性锁

  • 强制性锁比较好理解,它是一种强制性的要求,如果进程对文件上了强制性锁,其它的进程在没有获取 到文件锁的情况下是无法对文件进行访问的。其本质原因在于,强制性锁会让内核检查每一个 I/O 操作(譬 如 read()、write()),验证调用进程是否是该文件锁的拥有者,如果不是将无法访问文件。当一个文件被上 锁进行写入操作的时候,内核将阻止其它进程对其进行读写操作。采取强制性锁对性能的影响很大,每次进 行读写操作都必须检查文件锁。

flock()函数加锁

  • 先来学习系统调用 flock(),使用该函数可以对文件加锁或者解锁,但是 flock()函数只能产生建议性锁
#include <sys/file.h>
int flock(int fd, int operation);
  • fd:参数 fd 为文件描述符,指定需要加锁的文件。

  • operation:参数 operation 指定了操作方式,可以设置为以下值的其中一个:

    • LOCK_SH:在 fd 引用的文件上放置一把共享锁。所谓共享,指的便是多个进程可以拥有对同一 个文件的共享锁,该共享锁可被多个进程同时拥有。
    • LOCK_EX:在 fd 引用的文件上放置一把排它锁(或叫互斥锁)。所谓互斥,指的便是互斥锁只 能同时被一个进程所拥有。
    • LOCK_UN:解除文件锁定状态,解锁、释放锁。
    • LOCK_NB:表示以非阻塞方式获取锁。默认情况下,调用 flock()无法获取到文件锁时会阻塞、直 到其它进程释放锁为止,如果不想让程序被阻塞,可以指定 LOCK_NB 标志,如果无法获取到锁 应立刻返回(错误返回,并将 errno 设置为 EWOULDBLOCK),通常与 LOCK_SH 或 LOCK_EX 一起使用,通过位或运算符组合在一起。
  • 注意,虽然一个程序对文件加锁之后,另一个程序企图加锁文件会失败,但是另一个程序同样可以打开并且编辑这个文件。

#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/file.h>
#include <signal.h>
static int fd = -1; //文件描述符
/* 信号处理函数 */
static void sigint_handler(int sig)
{
if (SIGINT != sig)
return;
/* 解锁 */
flock(fd, LOCK_UN);
close(fd);
printf("进程 1: 文件已解锁!\n");
}
int main(int argc, char *argv[])
{
if (2 != argc)
{
fprintf(stderr, "usage: %s <file>\n", argv[0]);
exit(-1);
}
/* 打开文件 */
fd = open(argv[1], O_WRONLY);
if (-1 == fd)
{
perror("open error");
exit(-1);
}
/* 以非阻塞方式对文件加锁(排它锁) */
if (-1 == flock(fd, LOCK_EX | LOCK_NB))
{
perror("进程 1: 文件加锁失败");
exit(-1);
}
printf("进程 1: 文件加锁成功!\n");
/* 为 SIGINT 信号注册处理函数 */
signal(SIGINT, sigint_handler);
for (;;)
sleep(1);
}
  • 另一个试图读写文件的程序
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/file.h>
#include <string.h>
int main(int argc, char *argv[])
{
char buf[100] = "Hello World!";
int fd;
int len;
if (2 != argc)
{
fprintf(stderr, "usage: %s <file>\n", argv[0]);
exit(-1);
}
/* 打开文件 */
fd = open(argv[1], O_RDWR);
if (-1 == fd)
{
perror("open error");
exit(-1);
}
/* 以非阻塞方式对文件加锁(排它锁) */
if (-1 == flock(fd, LOCK_EX | LOCK_NB))
perror("进程 2: 文件加锁失败");
else
printf("进程 2: 文件加锁成功!\n");
/* 写文件 */
len = strlen(buf);
if (0 > write(fd, buf, len))
{
perror("write error");
exit(-1);
}
printf("进程 2: 写入到文件的字符串<%s>\n", buf);
/* 将文件读写位置移动到文件头 */
if (0 > lseek(fd, 0x0, SEEK_SET))
{
perror("lseek error");
exit(-1);
}
/* 读文件 */
memset(buf, 0x0, sizeof(buf)); //清理 buf
if (0 > read(fd, buf, len))
{
perror("read error");
exit(-1);
}
printf("进程 2: 从文件读取的字符串<%s>\n", buf);
/* 解锁、退出 */
flock(fd, LOCK_UN);
close(fd);
exit(0);
}
  • 使用 kill 命令向 testApp1 进程发送编号为 2 的信号,也就是 SIGIO 信号,testApp1 接收到信号之后, 对 infile 文件进行解锁、然后退出;接着再次执行 testApp2 程序,从打印信息可知,这次能够成功对 infile 文件加锁了,读写也是没有问题的。
  • 关于 flock()的几条规则
    • 同一进程对文件多次加锁不会导致死锁。当进程调用 flock()对文件加锁成功,再次调用 flock()对 文件(同一文件描述符)加锁,这样不会导致死锁,新加的锁会替换旧的锁。譬如调用 flock()对文 件加共享锁,再次调用 flock()对文件加排它锁,最终文件锁会由共享锁替换为排它锁。
    • 文件关闭的时候,会自动解锁。进程调用 flock()对文件加锁,如果在未解锁之前将文件关闭,则会 导致文件锁自动解锁,也就是说,文件锁会在相应的文件描述符被关闭之后自动释放。同理,当一 个进程终止时,它所建立的锁将全部释放。
    • 一个进程不可以对另一个进程持有的文件锁进行解锁。
    • 由 fork()创建的子进程不会继承父进程所创建的锁。这意味着,若一个进程对文件加锁成功,然后 该进程调用 fork()创建了子进程,那么对父进程创建的锁而言,子进程被视为另一个进程,虽然子 进程从父进程继承了其文件描述符,但不能继承文件锁。这个约束是有道理的,因为锁的作用就是 阻止多个进程同时写同一个文件,如果子进程通过 fork()继承了父进程的锁,则 父进程和子进程就 可以同时写同一个文件了
    • 除此之外,当一个文件描述符被复制时(譬如使用 dup()、dup2()或 fcntl()F_DUPFD 操作),这些通过 复制得到的文件描述符和源文件描述符都会引用同一个文件锁,使用这些文件描述符中的任何一个进行解 锁都可以

fcntl()函数加锁

#include <unistd.h>
#include <fcntl.h>
int fcntl(int fd, int cmd, ... /* struct flock *flockptr */ );
  • 与锁相关的 cmd 为 F_SETLK、F_SETLKW、F_GETLK,第三个参数 flockptr 是一个 struct flock 结构体 指针。使用 fcntl()实现文件锁功能与 flock()有两个比较大的区别:

    • flock()仅支持对整个文件进行加锁/解锁;而 fcntl()可以对文件的某个区域(某部分内容)进行加锁 /解锁,可以精确到某一个字节数据
    • flock()仅支持建议性锁类型;而 fcntl()可支持建议性锁和强制性锁两种类型。
  • 结构体参数如下

struct flock {
...
short l_type; /* Type of lock: F_RDLCK,F_WRLCK, F_UNLCK */
short l_whence; /* How to interpret l_start: SEEK_SET, SEEK_CUR, SEEK_END */
off_t l_start; /* Starting offset for lock */
off_t l_len; /* Number of bytes to lock */
pid_t l_pid; /* PID of process blocking our lock(set by F_GETLK and F_OFD_GETLK) */
...
};
  • 具体说明

    • l_type:所希望的锁类型,可以设置为 F_RDLCK、F_WRLCK 和 F_UNLCK 三种类型之一,F_RDLCK 表示共享性质的读锁,F_WRLCK 表示独占性质的写锁,F_UNLCK 表示解锁一个区域。
    • l_whence 和 l_start:这两个变量用于指定要加锁或解锁区域的起始字节偏移量,与 2.7 小节所学 的 lseek()函数中的 offset 和 whence 参数相同,这里不再重述,如果忘记了,可以回到 2.7 小节再 看看。
    • l_len:需要加锁或解锁区域的字节长度。
    • l_pid:一个 pid,指向一个进程,表示该进程持有的锁能阻塞当前进程,当 cmd=F_GETLK 时有效。
  • 几条规则

    • 锁区域可以在当前文件末尾处开始或者越过末尾处开始,但是不能在文件起始位置之前开始。
    • 若参数 l_len 设置为 0,表示将锁区域扩大到最大范围,也就是说从锁区域的起始位置开始,到文 件的最大偏移量处(也就是文件末尾)都处于锁区域范围内。而且是动态的,这意味着不管向该文 件追加写了多少数据,它们都处于锁区域范围,起始位置可以是文件的任意位置。
    • 如果我们需要对整个文件加锁,可以将 l_whence 和 l_start 设置为指向文件的起始位置,并且指定 参数 l_len 等于 0。
  • 锁的类型

  • 上面我们提到了两种类型的锁,分别为共享性读锁(F_RDLCK)和独占性写锁(F_WRLCK)。基本的 规则与 12.5 小节所介绍的线程同步读写锁很相似,任意多个进程在一个给定的字节上可以有一把共享的读 锁,但是在一个给定的字节上只能有一个进程有一把独占写锁,进一步而言,如果在一个给定的字节上已经 有一把或多把读锁,则不能在该字节上加写锁;如果在一个字节上已经有一把独占性写锁,则不能再对它加 任何锁(包括读锁和写锁)

image-20220126132857197

  • 如果一个进程对文件的某个区域已经上了一把锁,后来该进程又试图在该区域再加一把锁,那么通常新 加的锁将替换旧的锁。譬如,若某一进程在文件的 100200 字节区间有一把写锁,然后又试图在 100200 字 节区间再加一把读锁,那么该请求将会成功执行,原来的写锁会替换为读锁。

  • 当对文件的某一区域加读锁时,调用进程必须对该文件有读权限,譬如 open() 时 flags 参数指定了 O_RDONLY 或 O_RDWR;当对文件的某一区域加写锁时,调用进程必须对该文件有写 权限,譬如 open()时 flags 参数指定了 O_WRONLY 或 O_RDWR。

  • F_SETLK、F_SETLKW 和 F_GETLK

    • F_GETLK:这种用法一般用于测试,测试调用进程对文件加一把由参数 flockptr 指向的 struct flock 对象所描述的锁是否会加锁成功。如果加锁不成功,意味着该文件的这部分区域已经存在一把锁, 并且由另一进程所持有,并且调用进程加的锁与现有锁之间存在排斥关系,现有锁会阻止调用进程 想要加的锁,并且现有锁的信息将会重写参数 flockptr 指向的对象信息。如果不存在这种情况,也 就是说 flockptr 指向的 struct flock 对象所描述的锁会加锁成功,则除了将 struct flock 对象的 l_type 修改为 F_UNLCK 之外,结构体中的其它信息保持不变。
    • F_SETLK:对文件添加由 flockptr 指向的 struct flock 对象所描述的锁。譬如试图对文件的某一区 域加读锁(l_type 等于 F_RDLCK)或写锁(l_type 等于 F_WRLCK),如果加锁失败,那么 fcntl() 将立即出错返回,此时将 errno 设置为 EACCES 或 EAGAIN。也可用于清除由 flockptr 指向的 struct flock 对象所描述的锁(l_type 等于 F_UNLCK)。
    • F_SETLKW:此命令是 F_SETLK 的阻塞版本(命令名中的 W 表示等待 wait),如果所请求的读 锁或写锁因另一个进程当前已经对所请求区域的某部分进行了加锁,而导致请求失败,那么调用进 程将会进入阻塞状态。只有当请求的锁可用时,进程才会被唤醒。
  • 规则

    • 文件关闭的时候,会自动解锁。
    • 一个进程不可以对另一个进程持有的文件锁进行解锁。
    • 由 fork()创建的子进程不会继承父进程所创建的锁。
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
int main(int argc, char *argv[])
{
struct flock lock = {0};
int fd = -1;
char buf[] = "Hello World!";
/* 校验传参 */
if (2 != argc)
{
fprintf(stderr, "usage: %s <file>\n", argv[0]);
exit(-1);
}
/* 打开文件 */
fd = open(argv[1], O_WRONLY);
if (-1 == fd)
{
perror("open error");
exit(-1);
}
/* 对文件加锁 */
lock.l_type = F_WRLCK; //独占性写锁
lock.l_whence = SEEK_SET; //文件头部
lock.l_start = 0; //偏移量为 0
lock.l_len = 0;
if (-1 == fcntl(fd, F_SETLK, &lock))
{
perror("加锁失败");
exit(-1);
}
printf("对文件加锁成功!\n");
/* 对文件进行写操作 */
if (0 > write(fd, buf, strlen(buf)))
{
perror("write error");
exit(-1);
}
/* 解锁 */
lock.l_type = F_UNLCK; //解锁
fcntl(fd, F_SETLK, &lock);
/* 退出 */
close(fd);
exit(0);
}

  • 不同区域加锁
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
int main(int argc, char *argv[])
{
struct flock wr_lock = {0};
struct flock rd_lock = {0};
int fd = -1;
/* 校验传参 */
if (2 != argc)
{
fprintf(stderr, "usage: %s <file>\n", argv[0]);
exit(-1);
}
/* 打开文件 */
fd = open(argv[1], O_RDWR);
if (-1 == fd)
{
perror("open error");
exit(-1);
}
/* 将文件大小截断为 1024 字节 */
ftruncate(fd, 1024);
/* 对 100~200 字节区间加写锁 */
wr_lock.l_type = F_WRLCK;
wr_lock.l_whence = SEEK_SET;
wr_lock.l_start = 100;
wr_lock.l_len = 100;
if (-1 == fcntl(fd, F_SETLK, &wr_lock))
{
perror("加写锁失败");
exit(-1);
}
printf("加写锁成功!\n");
/* 对 400~500 字节区间加读锁 */
rd_lock.l_type = F_RDLCK;
rd_lock.l_whence = SEEK_SET;
rd_lock.l_start = 400;
rd_lock.l_len = 100;
if (-1 == fcntl(fd, F_SETLK, &rd_lock))
{
perror("加读锁失败");
exit(-1);
}
printf("加读锁成功!\n");
/* 对文件进行 I/O 操作 */
// ......
// ......
/* 解锁 */
wr_lock.l_type = F_UNLCK; //写锁解锁
fcntl(fd, F_SETLK, &wr_lock);
rd_lock.l_type = F_UNLCK; //读锁解锁
fcntl(fd, F_SETLK, &rd_lock);
/* 退出 */
close(fd);
exit(0);
}

  • 多个进程对同一文件的相同区域都可以加读锁,说明读锁是共享性的。由于程序 是放置在后台运行的,测试完毕之后,可以使用 kill 命令将这些进程杀死,或者直接关闭当前终端,重新启 动新的终端。
  • 第一次启动的进程对文件加写锁之后,后面再启动进程对同一文件的相同区域加写 锁发现都会失败,所以由此可知,写锁是独占性的。

锁的规则同上面的flock()函数

强制性锁会直接影响read()write()函数的操作(失败会报错),在此处略

Linux高级IO(二)

异步IO

  • 在异步 I/O 中,当文件描述符上可以执行 I/O 操作时,进程可以请求内核为自己发送一个信号。之后进程 就可以执行任何其它的任务直到文件描述符可以执行 I/O 操作为止,此时内核会发送信号给进程。
  • 要使用异步 I/O,程序需要按照如下步骤来执行:
    • 通过指定 O_NONBLOCK 标志使能非阻塞 I/O。
    • 通过指定 O_ASYNC 标志使能异步 I/O。
    • 设置异步 I/O 事件的接收进程。也就是当文件描述符上可执行 I/O 操作时会发送信号通知该进程, 通常将调用进程设置为异步 I/O 事件的接收进程。
    • 为内核发送的通知信号注册一个信号处理函数。默认情况下,异步 I/O 的通知信号是 SIGIO,所以 内核会给进程发送信号 SIGIO。在 8.2 小节中简单地提到过该信号。
    • 以上步骤完成之后,进程就可以执行其它任务了,当 I/O 操作就绪时,内核会向进程发送一个 SIGIO 信号,当进程接收到信号时,会执行预先注册好的信号处理函数,我们就可以在信号处理函数中进 行 I/O 操作。
  • O_ASYNC 标志
    • O_ASYNC 标志可用于使能文件描述符的异步 I/O 事件,当文件描述符可执行 I/O 操作时,内核会向异 步 I/O 事件的接收进程发送 SIGIO 信号(默认情况下)。
    • 在调用 open()时无法通过指定 O_ASYNC 标志来使能异步 I/O,但可以使用 fcntl()函数 添加 O_ASYNC 标志使能异步 I/O
int flag;
flag = fcntl(0, F_GETFL); //先获取原来的 flag
flag |= O_ASYNC; //将 O_ASYNC 标志添加到 flag
fcntl(fd, F_SETFL, flag); //重新设置 flag
  • 设置异步 I/O 事件的接收进程

    • 为文件描述符设置异步 I/O 事件的接收进程,也就是设置异步 I/O 的所有者。同样也是通过 fcntl()函数 进行设置,操作命令 cmd 设置为 F_SETOWN,第三个参数传入接收进程的进程 ID(PID),通常将调用进 程的 PID 传入
    • fcntl(fd, F_SETOWN, getpid());
  • 注册 SIGIO 信号的处理函数

    • 通过 signal()或 sigaction()函数为 SIGIO 信号注册一个信号处理函数,当进程接收到内核发送过来的 SIGIO 信号时,会执行该处理函数,所以我们应该在处理函数当中执行相应的 I/O 操作。
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <signal.h>
#define MOUSE "/dev/input/event3"
static int fd;
static void sigio_handler(int sig)
{
static int loops = 5;
char buf[100] = {0};
int ret;
if (SIGIO != sig)
return;
ret = read(fd, buf, sizeof(buf));
if (0 < ret)
printf("鼠标: 成功读取<%d>个字节数据\n", ret);
loops--;
if (0 >= loops)
{
close(fd);
exit(0);
}
}
int main(void)
{
int flag;
/* 打开鼠标设备文件<使能非阻塞 I/O> */
fd = open(MOUSE, O_RDONLY | O_NONBLOCK);
if (-1 == fd)
{
perror("open error");
exit(-1);
}
/* 使能异步 I/O */
flag = fcntl(fd, F_GETFL);
flag |= O_ASYNC;
fcntl(fd, F_SETFL, flag);
/* 设置异步 I/O 的所有者 */
fcntl(fd, F_SETOWN, getpid());
/* 为 SIGIO 信号注册信号处理函数 */
signal(SIGIO, sigio_handler);
for (;;)
sleep(1);
}

异步IO的优化

  • 在一个需要同时检查大量文件描述符(譬如数千个)的 应用程序中,例如某种类型的网络服务端程序,与 select()和 poll()相比,异步 I/O 能够提供显著的性能优势。 之所以如此,原因在于:对于异步 I/O,内核可以“记住”要检查的文件描述符,且仅当这些文件描述符上 可执行 I/O 操作时,内核才会向应用程序发送信号。

  • 问题

    • 默认的异步 I/O 通知信号 SIGIO 是非排队信号。SIGIO 信号是标准信号(非实时信号、不可靠信 号),所以它不支持信号排队机制,譬如当前正在执行 SIGIO 信号的处理函数,此时内核又发送 多次 SIGIO 信号给进程,这些信号将会被阻塞,只有当信号处理函数执行完毕之后才会传递给进 程,并且只能传递一次,而其它后续的信号都会丢失。
    • 无法得知文件描述符发生了什么事件。在示例代码 13.3.1 的信号处理函数 sigio_handler()中,直接 调用了 read()函数读取鼠标,而并未判断文件描述符是否处于可读就绪态,事实上,示例代码 13.3.1 这种异步 I/O 方式并未告知应用程序文件描述符上发生了什么事件,是可读取还是可写入亦或者 发生异常等。
  • 使用实时信号替换默认信号 SIGIO

  • SIGIO 作为异步 I/O 通知的默认信号,是一个非实时信号,我们可以设置不使用默认信号,指定一个实 时信号作为异步 I/O 通知信号,如何指定呢?同样也是使用 fcntl()函数进行设置,调用函数时将操作命令 cmd 参数设置为 F_SETSIG,第三个参数 arg 指定一个实时信号编号即可,表示将该信号作为异步 I/O 通知 信号

  • fcntl(fd, F_SETSIG, SIGRTMIN);

    • 如果第三个参数 arg 设置为 0,则表示指定 SIGIO 信号作为异步 I/O 通知信号,也就是回到了默认状态。
  • 使用 sigaction()函数注册信号处理函数

    • 在应用程序当中需要为实时信号注册信号处理函数,使用 sigaction 函数进行注册,并为 sa_flags 参数指 定 SA_SIGINFO,表示使用 sa_sigaction 指向的函数作为信号处理函数,而不使用 sa_handler 指向的函数。

    • sigaction函数的原型

#include <signal.h>
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
  • 函数参数中包括一个 siginfo_t 指针,指向 siginfo_t 类型对象,当触发信号时该对象由内核构建。siginfo_t 结构体中提供了很多信息,我们可以在信号处理函数中使用这些信息,具体定义请参考示例代码 8.4.3,就 对于异步 I/O 事件而言,传递给信号处理函数的 siginfo_t 结构体中与之相关的字段如下

    • si_signo:引发处理函数被调用的信号。这个值与信号处理函数的第一个参数一致。
    • si_fd:表示发生异步 I/O 事件的文件描述符;
    • si_code:表示文件描述符 si_fd 发生了什么事件,读就绪态、写就绪态或者是异常事件等。该字段 中可能出现的值以及它们对应的描述信息参见表 13.4.1。
    • si_band:是一个位掩码,其中包含的值与系统调用 poll()中返回的 revents 字段中的值相同。如表 13.4.1 所示,si_code 中可能出现的值与 si_band 中的位掩码有着一一对应关系。
      • image-20220125210615175
  • 可以在信号处理函数中通过对比 siginfo_t 结构体的 si_code 变量来检查文件描述符发 生了什么事件,以采取相应的 I/O 操作。

#define _GNU_SOURCE //在源文件开头定义_GNU_SOURCE 宏
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <signal.h>

#define MOUSE "/dev/input/mouse0"
static int fd;
static void io_handler(int sig,
siginfo_t *info,
void *context)
{
static int loops = 5;
char buf[100] = {0};
int ret;
if (SIGRTMIN != sig)
return;
/* 判断鼠标是否可读 */
if (POLL_IN == info->si_code)
{
ret = read(fd, buf, sizeof(buf));
if (0 < ret)
printf("鼠标: 成功读取<%d>个字节数据\n", ret);
loops--;
if (0 >= loops)
{
close(fd);
exit(0);
}
}
}
int main(void)
{
struct sigaction act;
int flag;
/* 打开鼠标设备文件<使能非阻塞 I/O> */
fd = open(MOUSE, O_RDONLY | O_NONBLOCK);
if (-1 == fd)
{
perror("open error");
exit(-1);
}
/* 使能异步 I/O */
flag = fcntl(fd, F_GETFL);
flag |= O_ASYNC;
fcntl(fd, F_SETFL, flag);
/* 设置异步 I/O 的所有者 */
fcntl(fd, F_SETOWN, getpid());
/* 指定实时信号 SIGRTMIN 作为异步 I/O 通知信号 */
fcntl(fd, F_SETSIG, SIGRTMIN);
/* 为实时信号 SIGRTMIN 注册信号处理函数 */
act.sa_sigaction = io_handler;
act.sa_flags = SA_SIGINFO;
sigemptyset(&act.sa_mask);
sigaction(SIGRTMIN, &act, NULL);
for (;;)
sleep(1);
}
  • 经过了使能异步flag、设置所有者线程、指定实时信号的种类、对sigation的对应的成员指定内容,包括处理函数、flag等等。
  • image-20220125212351275

存储映射I/O

  • 存储映射 I/O(memory-mapped I/O)是一种基于内存区域的高级 I/O 操作,它能将一个文件映射到进程 地址空间中的一块内存区域中,当从这段内存中读数据时,就相当于读文件中的数据(对文件进行 read 操 作),将数据写入这段内存时,则相当于将数据直接写入文件中(对文件进行 write 操作)。这样就可以在 不使用基本 I/O 操作函数 read()和 write()的情况下执行 I/O 操作。

  • 为了实现存储映射 I/O 这一功能,我们需要告诉内核将一个给定的文件映射到进程地址空间中的一块 内存区域中,这由系统调用 **mmap()**来实现。

#include <sys/mman.h>
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
  • addr:参数 addr 用于指定映射到内存区域的起始地址。通常将其设置为 NULL,这表示由系统选择该 映射区的起始地址,这是最常见的设置方式;如果参数 addr 不为 NULL,则表示由自己指定映射区的起始 地址,此函数的返回值是该映射区的起始地址。

  • length:参数 length 指定映射长度,表示将文件中的多大部分映射到内存区域中,以字节为单位,譬如 length=1024 * 4,表示将文件的 4K 字节大小映射到内存区域中。

  • offset:文件映射的偏移量,通常将其设置为 0,表示从文件头部开始映射;所以参数 offset 和参数 length 就确定了文件的起始位置和长度,将文件的这部分映射到内存区域中

    • image-20220125213944291
  • fd:文件描述符,指定要映射到内存区域中的文件。

  • prot:参数 prot 指定了映射区的保护要求,可取值如下:

    • PROT_EXEC:映射区可执行;
    • PROT_READ:映射区可读;
    • PROT_WRITE:映射区可写;
    • PROT_NONE:映射区不可访问。
  • 对指定映射区的保护要求不能超过文件 open()时的访问权限,譬 如,文件是以只读权限方式打开的,那么对映射区的不能指定为 PROT_WRITE。

  • flags:参数 flags 可影响映射区的多种属性,参数 flags 必须要指定以下两种标志之一:

    • image-20220125213454172

    • 通常情况下,参数 flags 中只指定了 MAP_SHARED

  • 返回值:成功情况下,函数的返回值便是映射区的起始地址;发生错误时,返回(void *)-1,通常使用 MAP_FAILED 来表示,并且会设置 errno 来指示错误原因。

  • 对于 mmap()函数,参数 addroffset 在不为 NULL 和 0 的情况下,addr 和 offset 的值通常被要求是系 统页大小的整数倍,可通过 sysconf()函数获取页大小

sysconf(_SC_PAGE_SIZE)
//或
sysconf(_SC_PAGESIZE)
  • 对于参数 length 任需要注意,参数 length 的值不能大于文件大小,即文件被映射的部分不能超出文件。

  • munmap()解除映射

#include <sys/mman.h>
int munmap(void *addr, size_t length);
  • munmap()系统调用解除指定地址范围内的映射,参数 addr 指定待解除映射地址范围的起始地址,它必 须是系统页大小的整数倍;参数 length 是一个非负整数,指定了待解除映射区域的大小(字节数),被解除 映射的区域对应的大小也必须是系统页大小的整数倍,即使参数 length 并不等于系统页大小的整数倍,与 mmap()函数相似。

  • 需要注意的是,当进程终止时也会自动解除映射(如果程序中没有显式调用 munmap()),但调用 close() 关闭文件时并不会解除映射

  • 通常将参数 addr 设置为 mmap()函数的返回值,将参数 length 设置为 mmap()函数的参数 length,表示解除整个由 mmap()函数所创建的映射。

#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/mman.h>
#include <string.h>

int main(int argc, char *argv[])
{
int srcfd, dstfd;
void *srcaddr;
void *dstaddr;
int ret;
struct stat sbuf;
if (3 != argc)
{
fprintf(stderr, "usage: %s <srcfile> <dstfile>\n", argv[0]);
exit(-1);
}
/* 打开源文件 */
srcfd = open(argv[1], O_RDONLY);
if (-1 == srcfd)
{
perror("open error");
exit(-1);
}
/* 打开目标文件 */
dstfd = open(argv[2], O_RDWR | O_CREAT | O_TRUNC, 0664);
if (-1 == dstfd)
{
perror("open error");
ret = -1;
goto out1;
}
/* 获取源文件的大小 */
fstat(srcfd, &sbuf);
/* 设置目标文件的大小 */
ftruncate(dstfd, sbuf.st_size);
/* 将源文件映射到内存区域中 */
srcaddr = mmap(NULL, sbuf.st_size,
PROT_READ, MAP_SHARED, srcfd, 0);
if (MAP_FAILED == srcaddr)
{
perror("mmap error");
ret = -1;
goto out2;
}
/* 将目标文件映射到内存区域中 */
dstaddr = mmap(NULL, sbuf.st_size,
PROT_WRITE, MAP_SHARED, dstfd, 0);
if (MAP_FAILED == dstaddr)
{
perror("mmap error");
ret = -1;
goto out3;
}
/* 将源文件中的内容复制到目标文件中 */
memcpy(dstaddr, srcaddr, sbuf.st_size);
/* 程序退出前清理工作 */
out4:
/* 解除目标文件映射 */
munmap(dstaddr, sbuf.st_size);
out3:
/* 解除源文件映射 */
munmap(srcaddr, sbuf.st_size);
out2:
/* 关闭目标文件 */
close(dstfd);
out1:
/* 关闭源文件并退出 */
close(srcfd);
exit(ret);
}
  • 当执行程序的时候,将源文件和目标文件传递给应用程序,该程序首先会将源文件和目标文件打开,源 文件以只读方式打开,而目标文件以可读、可写方式打开,如果目标文件不存在则创建它,并且将文件的大 小截断为 0。

  • 然后使用 fstat()函数获取源文件的大小,接着调用 ftruncate()函数设置目标文件的大小与源文件大小保 持一致。

  • 然后对源文件和目标文件分别调用 mmap(),将文件映射到内存当中;对于源文件,调用 mmap()时将参 数 prot 指定为 PROT_READ,表示对它的映射区会进行读取操作;对于目标文件,调用 mmap()时将参数 port 指定为 PROT_WRITE,表示对它的映射区会进行写入操作。最后调用 memcpy()将源文件映射区中的内容复 制到目标文件映射区中,完成文件的复制操作。

  • 使用系统调用 mprotect()可以更改一个现有映射区的保护要求

#include <sys/mman.h>
int mprotect(void *addr, size_t len, int prot);
  • 参数 prot 的取值与 mmap()函数的 prot 参数的一样,mprotect()函数会将指定地址范围的保护要求更改 为参数 prot 所指定的类型,参数 addr 指定该地址范围的起始地址,addr 的值必须是系统页大小的整数倍; 参数 len 指定该地址范围的大小。
  • 写入到文件映射区中的数据也不会立马刷新至磁盘设备中,而是会在我们 将数据写入到映射区之后的某个时刻将映射区中的数据写入磁盘中。所以会导致映射区中的内容与磁盘文 件中的内容不同步。我们可以调用 msync()函数将映射区中的数据刷写、更新至磁盘文件中(同步操作), 系统调用 msync()类似于 fsync()函数,不过 msync()作用于映射区。
#include <sys/mman.h>
int msync(void *addr, size_t length, int flags);
  • 参数 addr 和 length 指定了需同步的内存区域的起始地址和大小。对于参数 addr 来说,同样也要求必须 是系统页大小的整数倍,也就是与系统页大小对齐。譬如,调用 msync()时,将 addr 设置为 mmap()函数的 返回值,将 length 设置为 mmap()函数的 length 参数,将对文件的整个映射区进行同步操作。

  • 参数 flags 应指定为 MS_ASYNC 和 MS_SYNC 两个标志之一,除此之外,还可以根据需求选择是否指 定 MS_INVALIDATE 标志,作为一个可选标志。

    • MS_ASYNC:以异步方式进行同步操作。调用 msync()函数之后,并不会等待数据完全写入磁盘之 后才返回。
    • MS_SYNC:以同步方式进行同步操作。调用 msync()函数之后,需等待数据全部写入磁盘之后才 返回。
    • MS_INVALIDATE:是一个可选标志,请求使同一文件的其它映射无效(以便可以用刚写入的新 值更新它们)。
  • munmap()函数并不影响被映射的文件,也就是说,当调用 munmap()解除映射时并不会将映射区中的内 容写到磁盘文件中。如果 mmap()指定了 MAP_SHARED 标志,对于文件的更新,会在我们将数据写入到映 射区之后的某个时刻将映射区中的数据更新到磁盘文件中,由内核根据虚拟存储算法自动进行。

  • 如果 mmap()指定了 MAP_PRIVATE 标志,在解除映射之后,进程对映射区的修改将会丢弃!

普通IO函数和存储映射IO的对比

  • 普通IO

  • image-20220126122116008

  • 存储映射IO

  • image-20220126122127642

  • 首先非常直观的一点就是,使用存储映射 I/O 减少了数据的复制操作,所以在效率上会比普通 I/O 要 高,其次上面也讲了,普通 I/O 中间涉及到了很多的函数调用过程,这些都会导致普通 I/O 在效率上会比存 储映射 I/O 要低。

  • 应用层与内核 层是不能直接进行交互的,必须要通过操作系统提供的系统调用或库函数来与内核进行数据交互,包括操 作硬件。通过存储映射 I/O 将文件直接映射到应用程序地址空间中的一块内存区域中,也就是映射区;直接 将磁盘文件直接与映射区关联起来,不用调用 read()、write()系统调用,直接对映射区进行读写操作即可操 作磁盘上的文件,而磁盘文件中的数据也可反应到映射区中

  • 映射区就是应用层 与内核层之间的共享内存。

Linux高级IO

阻塞与非阻塞IO

  • 阻塞其实就是进入了休眠状态,交出了 CPU 控制权。

  • 譬如对于某些文件类型(读管道文件、网 络设备文件和字符设备文件),当对文件进行读操作时,如果数据未准备好、文件当前无数据可读,那么读 操作可能会使调用者阻塞,直到有数据可读时才会被唤醒,这就是阻塞式 I/O 常见的一种表现;如果是非阻 塞式 I/O,即使没有数据可读,也不会被阻塞、而是会立马返回错误!

  • 普通文件的读写操作是不会阻塞的,不管读写多少个字节数据,read()或 write()一定会在有限的时间内 返回,所以普通文件一定是以非阻塞的方式进行 I/O 操作,这是普通文件本质上决定的;但是对于某些文件 类型,譬如上面所介绍的管道文件、设备文件等,它们既可以使用阻塞式 I/O 操作,也可以使用非阻塞式 I/O 进行操作。

  • 控制台使用sudo od -x /dev/input/mouse0读取鼠标文件信息

#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
int main(void)
{
char buf[100];
int fd, ret;
/* 打开文件 */
fd = open("/dev/input/event3", O_RDONLY);
if (-1 == fd)
{
perror("open error");
exit(-1);
}
/* 读文件 */
memset(buf, 0, sizeof(buf));
ret = read(fd, buf, sizeof(buf));
if (0 > ret)
{
perror("read error");
close(fd);
exit(-1);
}
printf("成功读取<%d>个字节数据\n", ret);
/* 关闭文件 */
close(fd);
exit(0);
}

  • memset函数的作用是void *memset(void *str, int c, size_t n) 复制字符 c(一个无符号字符)到参数 str 所指向的字符串的前 n 个字符。上面代码中的作用是初始化为0

  • image-20220125105645727

  • 但是看到读取的并不是要求的100个字节

  • 假如将open的flag更改为O_RDONLY | O_NONBLOCK,那么调用程序假如没读取到的话就会立即报错,

  • image-20220125105756074

  • 所以阻塞式 I/O 的优点在于能够提升 CPU 的处理效率,当自身条件不满足时,进入阻塞状态,交出 CPU 资源,将 CPU 资源让给别人使用;而非阻塞式则是抓紧利用 CPU 资源,譬如不断地去轮训,这样就会导致 该程序占用了非常高的 CPU 使用率!

键盘

  • 键盘是标准输入设备 stdin,进程会自动从父进程中继承标准输入、标准输出以及标准错 误,标准输入设备对应的文件描述符为 0,所以在程序当中直接使用即可,不需要再调用 open 打开。

并发读取

  • 程序中先读了鼠标,在接着读键盘,所以由此可知,在实际测试当中,需要先动鼠标在按键盘(按 下键盘上的按键、按完之后按下回车),这样才能既成功读取鼠标、又成功读取键盘,程序才能够顺利运行 结束。因为 read 此时是阻塞式读取,先读取了鼠标,没有数据可读将会一直被阻塞,后面的读取键盘将得 不到执行。
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
#define MOUSE "/dev/input/event3"
int main(void)
{
char buf[100];
int fd, ret;
/* 打开鼠标设备文件 */
fd = open(MOUSE, O_RDONLY);
if (-1 == fd)
{
perror("open error");
exit(-1);
}
/* 读鼠标 */
memset(buf, 0, sizeof(buf));
ret = read(fd, buf, sizeof(buf));
printf("鼠标: 成功读取<%d>个字节数据\n", ret);
/* 读键盘 */
memset(buf, 0, sizeof(buf));
ret = read(0, buf, sizeof(buf));
printf("键盘: 成功读取<%d>个字节数据\n", ret);
/* 关闭文件 */
close(fd);
exit(0);
}

  • 利用下面的代码修改键盘文件的读取类型为不堵塞,并且同上将鼠标的打开方式也设置为非堵塞
int flag;
flag = fcntl(0, F_GETFL); //先获取原来的 flag
flag |= O_NONBLOCK; //将 O_NONBLOCK 标志添加到 flag
fcntl(0, F_SETFL, flag); //重新设置 flag
  • 使用非阻塞 I/O 方式解决了示例代码 13.1.4 出现的问题,但由于程序当中使用轮训方式,故而会 使得该程序的 CPU 占用率特别高,终归还是不太安全,会对整个系统产生很大的副作用,如何解决这样的 问题呢?我们将在下一小节向大家介绍。

多路复用

  • I/O 多路复用(IO multiplexing)它通过一种机制,可以监视多个文件描述符,一旦某个文件描述符(也 就是某个文件)可以执行 I/O 操作时,能够通知应用程序进行相应的读写操作。I/O 多路复用技术是为了解 决:在并发式 I/O 场景中进程或线程阻塞到某个 I/O 系统调用而出现的技术,使进程不阻塞于某个特定的 I/O 系统调用。

select()函数

  • 系统调用 select()可用于执行 I/O 多路复用操作,调用 select()会一直阻塞,直到某一个或多个文件描述 符成为就绪态(可以读或写)。
#include <sys/select.h>
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
  • 参数 readfds、writefds 以及 exceptfds 都是 fd_set 类型指针, 指向一个 fd_set 类型对象,fd_set 数据类型是一个文件描述符的集合体,所以参数 readfds、writefds 以及 exceptfds 都是指向文件描述符集合的指针,这些参数按照如下方式使用:

    • readfds 是用来检测读是否就绪(是否可读)的文件描述符集合;
    • writefds 是用来检测写是否就绪(是否可写)的文件描述符集合;
    • exceptfds 是用来检测异常情况是否发生的文件描述符集合。
  • 为 Linux 提供了四个宏用于对 fd_set 类型对象进行操作

    • FD_CLR()、FD_ISSET()、FD_SET()、FD_ZERO()
  • 如果对 readfds、writefds 以及 exceptfds 中的某些事件不感兴趣,可将其设置为 NULL,这表示对相应条 件不关心。如果这三个参数都设置为 NULL,则可以将 select()当做为一个类似于 sleep()休眠的函数来使用, 通过 select()函数的最后一个参数 timeout 来设置休眠时间。

  • select()函数的第一个参数 nfds 通常表示最大文件描述符编号值加 1,考虑 readfds、writefds 以及 exceptfds 这三个文件描述符集合,在 3 个描述符集中找出最大描述符编号值,然后加 1,这就是参数 nfds。

  • select()函数的最后一个参数 timeout 可用于设定 select()阻塞的时间上限,控制 select 的阻塞行为,可将 timeout 参数设置为 NULL,表示 select()将会一直阻塞、直到某一个或多个文件描述符成为就绪态;也可将 其指向一个 struct timeval 结构体对象

    • 如果参数 timeout 指向的 struct timeval 结构体对象中的两个成员变量都为 0,那么此时 select()函数不会 阻塞,它只是简单地轮训指定的文件描述符集合,看看其中是否有就绪的文件描述符并立刻返回。否则,参 数 timeout 将为 select()指定一个等待(阻塞)时间的上限值,如果在阻塞期间内,文件描述符集合中的某一 个或多个文件描述符成为就绪态,将会结束阻塞并返回;如果超过了阻塞时间的上限值,select()函数将会返 回!
  • select()函数将阻塞直到有以下事情发生:

    • readfds、writefds 或 exceptfds 指定的文件描述符中至少有一个称为就绪态;
    • 该调用被信号处理函数中断;
    • 参数 timeout 中指定的时间上限已经超时。
  • 文件描述集合的操作宏定义

    • FD_ZERO()将参数 set 所指向的集合初始化为空
    • FD_SET()将文件描述符 fd 添加到参数 set 所指向的集合中;
    • FD_CLR()将文件描述符 fd 从参数 set 所指向的集合中移除;
    • 如果文件描述符 fd 是参数 set 所指向的集合中的成员,则 FD_ISSET()返回 true,否则返回 false。
  • 返回值

    • 返回-1 表示有错误发生,并且会设置 errno。
    • 返回 0 表示在任何文件描述符成为就绪态之前 select()调用已经超时,在这种情况下,readfds, writefds 以及 exceptfds 所指向的文件描述符集合都会被清空。
    • 返回一个正整数表示有一个或多个文件描述符已达到就绪态。返回值表示处于就绪态的文件描述 符的个数,在这种情况下,每个返回的文件描述符集合都需要检查,通过 FD_ISSET()宏进行检查, 以此找出发生的 I/O 事件是什么。如果同一个文件描述符在 readfds,writefds 以及 exceptfds 中同时 被指定,且它对于多个 I/O 事件都处于就绪态的话,那么就会被统计多次
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/select.h>
#define MOUSE "/dev/input/event3"
int main(void)
{
char buf[100];
int fd, ret = 0, flag;
fd_set rdfds;
int loops = 5;
/* 打开鼠标设备文件 */
fd = open(MOUSE, O_RDONLY | O_NONBLOCK);
if (-1 == fd)
{
perror("open error");
exit(-1);
}
/* 将键盘设置为非阻塞方式 */
flag = fcntl(0, F_GETFL); //先获取原来的 flag
flag |= O_NONBLOCK; //将 O_NONBLOCK 标准添加到 flag
fcntl(0, F_SETFL, flag); //重新设置 flag
/* 同时读取键盘和鼠标 */
while (loops--)
{
FD_ZERO(&rdfds);
FD_SET(0, &rdfds); //添加键盘
FD_SET(fd, &rdfds); //添加鼠标
ret = select(fd + 1, &rdfds, NULL, NULL, NULL);
if (0 > ret)
{
perror("select error");
goto out;
}
else if (0 == ret)
{
fprintf(stderr, "select timeout.\n");
continue;
}
/* 检查键盘是否为就绪态 */
if (FD_ISSET(0, &rdfds))
{
ret = read(0, buf, sizeof(buf));
if (0 < ret)
printf("键盘: 成功读取<%d>个字节数据\n", ret);
}
/* 检查鼠标是否为就绪态 */
if (FD_ISSET(fd, &rdfds))
{
ret = read(fd, buf, sizeof(buf));
if (0 < ret)
printf("鼠标: 成功读取<%d>个字节数据\n", ret);
}
}
out:
/* 关闭文件 */
close(fd);
exit(ret);
}

  • 程序的工作实际上是先利用鼠标和键盘的文件位置构建一个结构体,然后使用select进行检测,然后检测结果之后对每个文件使用宏定义进行判断是否是这个文件处于就绪状态。
  • 鼠标和键盘都设置为了非阻塞 I/O 方式,其实设置为阻塞 I/O 方式也是可以的,因 为 select()返回时意味着此时数据是可读取的,所以以非阻塞和阻塞两种方式读取数据均不会发生阻塞

poll()函数

  • 系统调用 poll()与 select()函数很相似,但函数接口有所不同。在 select()函数中,我们提供三个 fd_set 集 合,在每个集合中添加我们关心的文件描述符;而在 poll()函数中,则需要构造一个 struct pollfd 类型的数 组,每个数组元素指定一个文件描述符以及我们对该文件描述符所关心的条件(数据可读、可写或异常情 况)。
#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
  • fds:指向一个 struct pollfd 类型的数组,数组中的每个元素都会指定一个文件描述符以及我们对该文件 描述符所关心的条件,稍后介绍 struct pollfd 结构体类型。
  • nfds:参数 nfds 指定了 fds 数组中的元素个数,数据类型 nfds_t 实际为无符号整形。
  • timeout:该参数与 select()函数的 timeout 参数相似,用于决定 poll()函数的阻塞行为,具体用法如下:
    • 如果 timeout 等于**-1,则 poll()会一直阻塞**(与 select()函数的 timeout 等于 NULL 相同),直到 fds 数组中列出的文件描述符有一个达到就绪态或者捕获到一个信号时返回。
    • 如果 timeout 等于 0,poll()不会阻塞,只是执行一次检查看看哪个文件描述符处于就绪态。
    • 如果 timeout 大于 0,则表示设置 poll()函数阻塞时间的上限值,意味着 poll()函数最多阻塞 timeout 毫秒,直到 fds 数组中列出的文件描述符有一个达到就绪态或者捕获到一个信号为止。
  • pollfd结构体
struct pollfd {
int fd; /* file descriptor */
short events; /* requested events */
short revents; /* returned events */
};
  • events 和 revents 都是位掩码

    • 初始化 events 来指 定需要为文件描述符 fd 做检查的事件

    • revents 变量由 poll()函数内部进行设置,用于 说明文件描述符 fd 发生了哪些事件(注意,poll()没有更改 events 变量)

    • 如果我们对某个文件描述符上的事件不感兴趣,则可将 events 变量设置为 0;另外,将 fd 变量设置为 文件描述符的负值(取文件描述符 fd 的相反数-fd),将导致对应的 events 变量被 poll()忽略,并且 revents 变量将总是返回 0,这两种方法都可用来关闭对某个文件描述符的检查。

    • image-20220125133340426

  • 返回值与select类似,略

#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <poll.h>
#define MOUSE "/dev/input/event3"
int main(void)
{
char buf[100];
int fd, ret = 0, flag;
int loops = 5;
struct pollfd fds[2];
/* 打开鼠标设备文件 */
fd = open(MOUSE, O_RDONLY | O_NONBLOCK);
if (-1 == fd)
{
perror("open error");
exit(-1);
}
/* 将键盘设置为非阻塞方式 */
flag = fcntl(0, F_GETFL); //先获取原来的 flag
flag |= O_NONBLOCK; //将 O_NONBLOCK 标准添加到 flag
fcntl(0, F_SETFL, flag); //重新设置 flag
/* 同时读取键盘和鼠标 */
fds[0].fd = 0;
fds[0].events = POLLIN; //只关心数据可读
fds[0].revents = 0;
fds[1].fd = fd;
fds[1].events = POLLIN; //只关心数据可读
fds[1].revents = 0;
while (loops--)
{
ret = poll(fds, 2, -1);
if (0 > ret)
{
perror("poll error");
goto out;
}
else if (0 == ret)
{
fprintf(stderr, "poll timeout.\n");
continue;
}
/* 检查键盘是否为就绪态 */
if (fds[0].revents & POLLIN)
{
ret = read(0, buf, sizeof(buf));
if (0 < ret)
printf("键盘: 成功读取<%d>个字节数据\n", ret);
}
/* 检查鼠标是否为就绪态 */
if (fds[1].revents & POLLIN)
{
ret = read(fd, buf, sizeof(buf));
if (0 < ret)
printf("鼠标: 成功读取<%d>个字节数据\n", ret);
}
}
out:
/* 关闭文件 */
close(fd);
exit(ret);
}

链表相关题目题解

返回链表倒数第k个元素

  • 快慢指针,第一个指针先移动k步,然后第二个指针再从头开始,这个时候这两个指针同时移动,当第一个指针到链表的末尾的时候,返回第二个指针即可
class Solution:
def FindKthToTail(self , pHead , k ):
# write code here
# 快慢指针
fast = pHead
slow = pHead
# 快指针先走k步
for i in range(0, k):
if not fast:
return None
fast = fast.next
# 双指针同时行走
while fast:
fast = fast.next
slow = slow.next
return slow

  • 。把原链表的结点全部压栈,然后再把栈中最上面的k个节点出栈,出栈的结点重新串成一个新的链表即可

  • image-20220124130552601

链表反序

  • 直接遍历
  • 递归

链表公共节点

  • 给定两个单链表A,B,假设一定含有公共结点,返回第一个公共结点的指针。

  • 双指针

  • image-20220124132357395

  • image-20220124132433562

class Solution {
public:
ListNode* FindFirstCommonNode( ListNode* pHead1, ListNode* pHead2) {
ListNode *ta = pHead1, *tb = pHead2;
while (ta != tb) {
ta = ta ? ta->next : pHead2;
tb = tb ? tb->next : pHead1;
}
return ta;
}
};
  • 上述代码是从第一个链表的尾部循环到第二个链表的头部,第二个链表从尾部循环到第一个链表的头部,相当于是把一个链表完整的连接在了另一个后面构成一个长度为m+n的新的链表,长度相等,而且保留原来的相同的节点

找带环链表的入口位置

  • 返回带有环的链表的环的入口节点

  • image-20220124134956992

  • 理解:假设从开始到相遇位置的总距离是x+y,x是直线距离,y是在环上的距离,那么此时快指针走过的路径长度是2(x+y),也就意味着环上剩余的长度为x+y,此时将其中一个指针移动到头部,另一个保持在原地,二者都按照1的步长运动,走到入口节点的距离是相同的都是x+y,所以必定会在环的入口相遇

class Solution {
public:
ListNode *detectCycle(ListNode *head) {
ListNode* fast = head;
ListNode* slow = head;
while (true) {
if (fast == nullptr || fast->next == nullptr) return nullptr;
fast = fast->next->next;
slow = slow->next;
if (fast == slow) break;
}
fast = head;
while (slow != fast) {
slow = slow->next;
fast = fast->next;
}
return fast;
}
};
- 相似的思路适用于Leetcode 287. 寻找重复数,寻找一个数字范围与数组长度相同的列表中的某一个(只有一个)重复数字。

leetcode 146. LRU缓存

  • 实现一个在容量上限达到之后弹出上次使用时间到现在最长的缓存项目的数据结构
    struct cacheLine
    {
    int value;
    int key;
    cacheLine* next;
    cacheLine* prev;
    cacheLine(int b, int a):value(a), key(b), next(NULL), prev(NULL){}
    };

    class LRUCache {
    public:
    int capa;
    int usedCnt;
    cacheLine* head;
    cacheLine* tail;
    unordered_map<int, cacheLine*> m;
    LRUCache(int capacity) {
    capa = capacity;
    usedCnt = 0;
    head = new cacheLine(-999, 0);
    }

    int get(int key)
    {
    if(m.count(key))
    {
    if(usedCnt>1&&m[key]->prev != head)
    {
    cacheLine* temp = head->next;
    cacheLine* thisNode = m[key];
    if(thisNode == tail)
    {
    tail = tail->prev;
    }
    if(thisNode->next)
    {
    thisNode->next->prev = thisNode->prev;
    }

    thisNode->prev->next = thisNode->next;
    head->next = thisNode;

    thisNode->next = temp;
    thisNode->prev = head;
    temp->prev = thisNode;

    if(temp)
    thisNode->next->prev =thisNode;
    return thisNode->value;
    }
    else return m[key]->value;
    }
    else return -1;
    }

    void put(int key, int value)
    {
    if(m.count(key))
    {
    m[key]->value = value;
    get(key);
    return;
    }
    if(usedCnt>0 && usedCnt<capa)
    {
    cacheLine* temp = head->next;
    cacheLine* newNode = new cacheLine(key, value);
    temp->prev = newNode;
    head->next = newNode;
    newNode->prev = head;
    newNode->next = temp;
    m[key] = head->next;
    ++usedCnt;
    }
    else if(usedCnt == 0)
    {
    head->next = new cacheLine(key, value);
    m[key] = head->next;
    head->next->prev = head;
    tail = head->next;
    ++usedCnt;
    }
    else if(usedCnt>1 && usedCnt == capa)
    {
    m.erase(m.find(tail->key));
    tail->key = key;
    tail->value = value;
    m[key] = tail;
    get(key);
    }
    else
    {
    m.erase(m.find(tail->key));
    tail->key = key;
    tail->value = value;
    m[key] = tail;
    }
    }
    };

    /**
    * Your LRUCache object will be instantiated and called as such:
    * LRUCache* obj = new LRUCache(capacity);
    * int param_1 = obj->get(key);
    * obj->put(key,value);
    */

    链表选择排序

  • 注意处理链表尾部和头部的问题
  • 建立一个假头部
    class Solution {
    public:
    ListNode* insertionSortList(ListNode* head) {
    ListNode* dumHead = new ListNode(INT_MIN);
    dumHead->next = head;
    ListNode* newHead = dumHead, *cur = head;
    int minVal = INT_MAX;
    ListNode* prevOfMin;
    while(newHead->next!=NULL)
    {
    cur = newHead;
    while(cur->next!=NULL)
    {
    if(cur->next->val<minVal)
    {
    prevOfMin = cur;
    minVal = cur->next->val;
    }
    cur = cur->next;
    }
    minVal = INT_MAX;
    if(prevOfMin == newHead)
    {
    newHead = newHead->next;
    continue;
    }

    ListNode* temp = newHead->next, *temp2 = prevOfMin->next->next;
    newHead->next = prevOfMin->next;
    prevOfMin->next->next = temp;

    prevOfMin->next = temp2;
    if(newHead->next)
    newHead = newHead->next;
    }
    return dumHead->next;
    }

    };

链表插入排序

  • 注意首先选择一个元素作为要插入的元素(保存这个元素的上一个元素这样方便修改,否则无法修改)
  • 注意没找到插入点的处理方法
    class Solution {
    public:
    ListNode* insertionSortList(ListNode* head) {
    ListNode* dumHead = new ListNode(INT_MIN);
    dumHead->next = head;
    ListNode* cur, *pick = dumHead->next;
    int temp;
    while(pick->next)
    {
    cur = dumHead;
    while((cur->next)&&(cur->next!=pick->next)&&(cur->next->val<=pick->next->val))
    {
    cur = cur->next;
    }
    if(cur->next == pick->next)
    {
    pick = pick->next;
    continue;
    }
    ListNode* temp = cur->next;
    ListNode*temp2 = pick->next->next;
    cur->next = pick->next;
    pick->next = temp2;
    cur->next->next = temp;
    }
    return dumHead->next;
    }

    };

    Leetcode 206.反转链表

  • 这题使用双指针方式不需要额外内存
  • 注意记得消除第一个节点(也就是反转后最后一个节点)的next为NULL,防止内存错误
    /**
    * Definition for singly-linked list.
    * struct ListNode {
    * int val;
    * ListNode *next;
    * ListNode() : val(0), next(nullptr) {}
    * ListNode(int x) : val(x), next(nullptr) {}
    * ListNode(int x, ListNode *next) : val(x), next(next) {}
    * };
    */
    class Solution {
    public:
    ListNode* reverseList(ListNode* head) {
    if(!head || !(head->next))return head;
    if(head->next->next == NULL)
    {
    ListNode* tmp = head->next;
    head->next = NULL;
    tmp->next = head;
    return tmp;
    }
    ListNode* next, *cur, *tmp;
    cur = head;
    tmp = head->next;
    next = head->next->next;
    cur->next = NULL;
    while(next)
    {
    tmp->next = cur;
    cur = tmp;
    tmp = next;
    next = next->next;
    }
    tmp->next = cur;

    return tmp;
    }
    };

151. 反转字符串中的单词

  • 此题是先将整个字符串去掉多余的空格,然后将整个字符串反转(reverse),然后再将字符串中的每个单词字母反转
    class Solution {
    public:
    string reverseWords(string s) {
    int from = 0, to = 0;
    while(s[from] == ' ')++from;
    while(from<s.size())
    {
    if(s[from] == ' ')
    {
    s[to++] = s[from++];
    while(from<s.size() && s[from] == ' ')
    {
    ++from;
    }
    }
    if(from<s.size())
    s[to++] = s[from++];
    }
    s.resize(to);
    while(s.back() == ' ')s.erase(s.end()-1);
    reverse(s.begin(), s.end());
    int start = 0;
    while(start<s.size())
    {
    int beg = start, end;
    while(start<s.size() && s[start]!=' ')
    {
    start++;
    }
    end = start-1;
    while(beg<end)
    {
    char tmp = s[end];
    s[end] = s[beg];
    s[beg] = tmp;
    end--;
    beg++;
    }
    start++;
    }
    return s;
    }
    };

Leetcode 202. 快乐数

  • 用快慢指针的方法,一个指针一次走两步,一个一次走一步
    class Solution {
    public:
    int bitSquareSum(int n) {
    int sum = 0;
    while(n > 0)
    {
    int bit = n % 10;
    sum += bit * bit;
    n = n / 10;
    }
    return sum;
    }

    bool isHappy(int n) {
    int slow = n, fast = n;
    do{
    slow = bitSquareSum(slow);
    fast = bitSquareSum(fast);
    fast = bitSquareSum(fast);
    }while(slow != fast);

    return slow == 1;
    }
    };

线程同步

互斥锁

互斥锁(mutex)又叫互斥量,从本质上说是一把锁,在访问共享资源之前对互斥锁进行上锁,在访问 完成后释放互斥锁(解锁);对互斥锁进行上锁之后,任何其它试图再次对互斥锁进行加锁的线程都会被阻塞,直到当前线程释放互斥锁

初始化

  • 互 斥锁使 用 pthread_mutex_t 数 据类型 表示, pthread_mutex_t 其 实是一个 结构体 类型, 而宏 PTHREAD_MUTEX_INITIALIZER 其实是一个对结构体赋值操作的封装
  • 使用宏定义初始化互斥锁:
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER
  • 使用函数初始化互斥锁
#include <pthread.h>
int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *attr);
  • mutex:参数 mutex 是一个 pthread_mutex_t 类型指针,指向需要进行初始化操作的互斥锁对象;
  • attr:参数 attr 是一个 pthread_mutexattr_t 类型指针,指向一个 pthread_mutexattr_t 类型对象,该对象用 于定义互斥锁的属性,若将参数 attr 设置为 NULL,则表示将互斥锁的属性设置为 默认值,在这种情况下其实就等价于 PTHREAD_MUTEX_INITIALIZER 这种方式初始化,而不同之处在于, 使用宏不进行错误检查。
  • 返回值:成功返回 0;失败将返回一个非 0 的错误码。
pthread_mutex_t mutex;
pthread_mutex_init(&mutex, NULL);

//或者
pthread_mutex_t *mutex = malloc(sizeof(pthread_mutex_t));
pthread_mutex_init(mutex, NULL);

互斥锁的属性

  • 如果不使用默认属性,在调用 pthread_mutex_init()函数时,参数 attr 必须要指向一个 pthread_mutexattr_t 对象,而不能使用 NULL。当定义 pthread_mutexattr_t 对象之后,需要使用 pthread_mutexattr_init()函数对该 对象进行初始化操作,当对象不再使用时,需要使用 pthread_mutexattr_destroy()将其销毁
#include <pthread.h>
int pthread_mutexattr_destroy(pthread_mutexattr_t *attr);
int pthread_mutexattr_init(pthread_mutexattr_t *attr);
  • 属性的类型
    • PTHREAD_MUTEX_NORMAL:一种标准的互斥锁类型,不做任何的错误检查或死锁检测。如果 线程试图对已经由自己锁定的互斥锁再次进行加锁,则发生死锁;互斥锁处于未锁定状态,或者已 由其它线程锁定,对其解锁会导致不确定结果。
    • PTHREAD_MUTEX_ERRORCHECK:此类互斥锁会提供错误检查。譬如这三种情况都会导致返 回错误:线程试图对已经由自己锁定的互斥锁再次进行加锁(同一线程对同一互斥锁加锁两次), 返回错误;线程对由其它线程锁定的互斥锁进行解锁,返回错误;线程对处于未锁定状态的互斥锁 进行解锁,返回错误。这类互斥锁运行起来比较慢,因为它需要做错误检查,不过可将其作为调试 工具,以发现程序哪里违反了互斥锁使用的基本原则。
    • PTHREAD_MUTEX_RECURSIVE:此类互斥锁允许同一线程在互斥锁解锁之前对该互斥锁进行 多次加锁,然后维护互斥锁加锁的次数,把这种互斥锁称为递归互斥锁,但是如果解锁次数不等于加速次数,则是不会释放锁的;所以,如果对一个递归互斥锁加锁两次,然后解锁一次,那么这个 互斥锁依然处于锁定状态,对它再次进行解锁之前不会释放该锁。
    • PTHREAD_MUTEX_DEFAULT : 此 类 互 斥 锁 提 供 默 认 的 行 为 和 特 性 。 使 用 宏 PTHREAD_MUTEX_INITIALIZER 初 始 化 的 互 斥 锁 , 或 者 调 用 参 数 arg 为 NULL 的 pthread_mutexattr_init()函数所创建的互斥锁,都属于此类型。此类锁意在为互斥锁的实现保留最大 灵活性, Linux 上 , PTHREAD_MUTEX_DEFAULT 类 型 互 斥 锁 的 行 为 与 PTHREAD_MUTEX_NORMAL 类型相仿。

互斥锁加锁和解锁

  • 互斥锁初始化之后,处于一个未锁定状态,调用函数 pthread_mutex_lock()可以对互斥锁加锁、获取互 斥锁,而调用函数 pthread_mutex_unlock()可以对互斥锁解锁、释放互斥锁。
#include <pthread.h>
//加锁
int pthread_mutex_lock(pthread_mutex_t *mutex);
//解锁
int pthread_mutex_unlock(pthread_mutex_t *mutex);
  • 如果互斥锁处于未锁定状态,则此次调用会上锁成 功,函数调用将立马返回;如果互斥锁此时已经被其它线程锁定了,那么调用 pthread_mutex_lock()会一直 阻塞,直到该互斥锁被解锁,到那时,调用将锁定互斥锁并返回

  • 调用 pthread_mutex_unlock()函数将已经处于锁定状态的互斥锁进行解锁。以下行为均属错误:

    • 对处于未锁定状态的互斥锁进行解锁操作;
    • 解锁由其它线程锁定的互斥锁。
  • 如果线程加锁的时候不希望被阻塞,可以使用 pthread_mutex_trylock()函数

    • 如果 互斥锁已经被其它线程锁住,调用 pthread_mutex_trylock()加锁失败,但不会阻塞,而是返回错误码 EBUSY。
#include <pthread.h>
int pthread_mutex_trylock(pthread_mutex_t *mutex);

销毁互斥锁

  • 调用 pthread_mutex_destroy()函数来销毁互斥锁
#include <pthread.h>
int pthread_mutex_destroy(pthread_mutex_t *mutex);
  • 不能销毁还没有解锁的互斥锁,否则将会出现错误;
  • 没有初始化的互斥锁也不能销毁。

互斥锁死锁

有时,一个线程需要同时访问两个或更多不同的共享资源,而每个资源又由不同的互斥锁管理。当超过 一个线程对同一组互斥锁(两个或两个以上的互斥锁)进行加锁时,就有可能发生死锁;譬如,程序中使用 一个以上的互斥锁,如果允许一个线程一直占有第一个互斥锁,并且在试图锁住第二个互斥锁时处于阻塞 状态,但是拥有第二个互斥锁的线程也在试图锁住第一个互斥锁。因为两个线程都在相互请求另一个线程 拥有的资源,所以这两个线程都无法向前运行,会被一直阻塞,于是就产生了死锁。

条件变量

条件变量是线程可用的另一种同步机制。条件变量用于自动阻塞线程,知道某个特定事件发生或某个条 件满足为止,通常情况下,条件变量是和互斥锁一起搭配使用的。条件变量包括

  • 一个线程等待某个条件满足而被阻塞
  • 另一个线程中,条件满足时发出“信号”

条件变量初始化

  • 使用宏定义初始化条件变量
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
  • pthread_cond_init()函数原型如下所示
#include <pthread.h>
int pthread_cond_destroy(pthread_cond_t *cond);
int pthread_cond_init(pthread_cond_t *cond, const pthread_condattr_t *attr);
  • 使用这些函数需要包含头文件,使用 pthread_cond_init()函数初始化条件变量,当不再 使用时,使用 pthread_cond_destroy()销毁条件变量。

  • 参数 cond 指向 pthread_cond_t 条件变量对象,对于 pthread_cond_init()函数,类似于互斥锁,在初始化 条件变量时设置条件变量的属性,参数 attr 指向一个 pthread_condattr_t 类型对象,pthread_condattr_t 数据类 型用于描述条件变量的属性。可将参数 attr 设置为 NULL,表示使用属性的默认值来初始化条件变量,与使 用 PTHREAD_COND_INITIALIZER 宏相同。

  • 注意事项

    • 在使用条件变量之前必须对条件变量进行初始化操作,使用 PTHREAD_COND_INITIALIZER 宏或 者函数 pthread_cond_init()都行;
    • 对已经初始化的条件变量再次进行初始化,将可能会导致未定义行为;
    • 对没有进行初始化的条件变量进行销毁,也将可能会导致未定义行为;
    • 对某个条件变量而言,仅当没有任何线程等待它时,将其销毁才是最安全的;
    • 经 pthread_cond_destroy()销毁的条件变量,可以再次调用 pthread_cond_init()对其进行重新初始化。

通知和等待

  • 发送信号操作即是通知一个或多个处于等待状态 的线程,某个共享变量的状态已经改变,这些处于等待状态的线程收到通知之后便会被唤醒,唤醒之后再检 查条件是否满足。等待操作是指在收到一个通知前一直处于阻塞状态。
  • 函数 pthread_cond_signal()和 pthread_cond_broadcast()均可向指定的条件变量发送信号,通知一个或多 个处于等待状态的线程。调用 pthread_cond_wait()函数是线程阻塞,直到收到条件变量的通知。
#include <pthread.h>
int pthread_cond_broadcast(pthread_cond_t *cond);
int pthread_cond_signal(pthread_cond_t *cond);
  • pthread_cond_signal()函数至少能唤醒一个线程,而 pthread_cond_broadcast()函数 则能唤醒所有线程。
  • 使用 pthread_cond_broadcast()函数总能产生正确的结果,唤醒所有等待状态的线程,但函数 pthread_cond_signal()会更为高效,因为它只需确保至少唤醒一个线程即可
#include <pthread.h>
int pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex);
  • 当程序当中使用条件变量,当判断某个条件不满足时,调用 pthread_cond_wait()函数将线程设置为等待 状态(阻塞)。

  • cond:指向需要等待的条件变量,目标条件变量;

  • mutex:参数 mutex 是一个 pthread_mutex_t 类型指针,指向一个互斥锁对象;前面开头便给大家介绍 了,条件变量通常是和互斥锁一起使用,因为条件的检测(条件检测通常是需要访问共享资源的)是在互斥 锁的保护下进行的,也就是说条件本身是由互斥锁保护的

    • 在 pthread_cond_wait()函数内部会对参数 mutex 所指定的互斥锁进行操作,通常情况下,条件判断以及 pthread_cond_wait()函数调用均在互斥锁的保护下,也就是说,在此之前线程已经对互斥锁加锁了。调用 pthread_cond_wait()函数时,调用者把互斥锁传递给函数,函数会自动把调用线程放到等待条件的线程列表 上,然后将互斥锁解锁;当 pthread_cond_wait()被唤醒返回时,会再次锁住互斥锁
  • 如果调用 pthread_cond_signal()和 pthread_cond_broadcast()向指定条件变量发送信号时,若无任何线程等待该条件变量, 这个信号也就会不了了之。

  • 当调用 pthread_cond_broadcast()同时唤醒所有线程时,互斥锁也只能被某一线程锁住,其它线程获取锁 失败又会陷入阻塞。

#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <unistd.h>
#include <string.h>
static pthread_mutex_t mutex; //定义互斥锁
static pthread_cond_t cond; //定义条件变量
static int g_avail = 0; //全局共享资源
/* 消费者线程 */
static void *consumer_thread(void *arg)
{
for (;;)
{
pthread_mutex_lock(&mutex); //上锁
while (0 >= g_avail)
pthread_cond_wait(&cond, &mutex); //等待条件满足
while (0 < g_avail)
g_avail--; //消费
pthread_mutex_unlock(&mutex); //解锁
}
return (void *)0;
}
/* 主线程(生产者) */
int main(int argc, char *argv[])
{
pthread_t tid;
int ret;
/* 初始化互斥锁和条件变量 */
pthread_mutex_init(&mutex, NULL);
pthread_cond_init(&cond, NULL);
/* 创建新线程 */
ret = pthread_create(&tid, NULL, consumer_thread, NULL);
if (ret)
{
fprintf(stderr, "pthread_create error: %s\n", strerror(ret));
exit(-1);
}
for (;;)
{
pthread_mutex_lock(&mutex); //上锁
g_avail++; //生产
pthread_mutex_unlock(&mutex); //解锁
pthread_cond_signal(&cond); //向条件变量发送信号
}
exit(0);
}

  • 上面的程序实现的功能是主线程对一个变量上互斥锁,+1,解锁互斥锁,然后发送信号唤醒子线程,线程对变量进行加锁,等待信号量,然后对变量-1,然后在解锁。

自旋锁

如果在获取自旋锁时,自旋锁处于未锁定状态,那么将立即获得锁(对自旋锁上锁);如果在获取自旋 锁时,自旋锁已经处于锁定状态了,那么获取锁操作将会在原地“自旋”,直到该自旋锁的持有者释放了锁。 由此介绍可知,自旋锁与互斥锁相似,但是互斥锁在无法获取到锁时会让线程陷入阻塞等待状态;而自旋锁 在无法获取到锁时,将会在原地“自旋”等待。“自旋”其实就是调用者一直在循环查看该自旋锁的持有者 是否已经释放了锁,“自旋”一词因此得名。

  • 自旋锁的不足之处在于:自旋锁一直占用的 CPU,它在未获得锁的情况下,一直处于运行状态(自旋), 所以占着 CPU,如果不能在很短的时间内获取锁,这无疑会使 CPU 效率降低。

  • 自旋锁通常用于以下情况:需要保护的代码段执行时间很短,这样就会使 得持有锁的线程会很快释放锁,而“自旋”等待的线程也只需等待很短的时间;在这种情况下就比较适合使 用自旋锁,效率高!

  • 区别

    • 实现方式上的区别:互斥锁是基于自旋锁而实现的,所以自旋锁相较于互斥锁更加底层;
    • 开销上的区别:获取不到互斥锁会陷入阻塞状态(休眠),直到获取到锁时被唤醒;而获取不到自 旋锁会在原地“自旋”,直到获取到锁;休眠与唤醒开销是很大的,所以互斥锁的开销要远高于自 旋锁、自旋锁的效率远高于互斥锁;但如果长时间的“自旋”等待,会使得 CPU 使用效率降低, 故自旋锁不适用于等待时间比较长的情况。
    • 使用场景的区别:自旋锁在用户态应用程序中使用的比较少,通常在内核代码中使用比较多;因为 自旋锁可以在中断服务函数中使用,而互斥锁则不行,在执行中断服务函数时要求不能休眠、不能 被抢占(内核中使用自旋锁会自动禁止抢占),一旦休眠意味着执行中断服务函数时主动交出了 CPU 使用权,休眠结束时无法返回到中断服务函数中,这样就会导致死锁!

初始化

  • 使用 pthread_spin_init()函数对其 进行初始化,当不再使用自旋锁时,调用 pthread_spin_destroy()函数将其销毁
#include <pthread.h>
int pthread_spin_destroy(pthread_spinlock_t *lock);
int pthread_spin_init(pthread_spinlock_t *lock, int pshared);
  • 参数 lock 指向了需要进行初始化或销毁的自旋锁对象,参数 pshared 表示自旋锁的进程共享属性,可以 取值如下:
    • PTHREAD_PROCESS_SHARED:共享自旋锁。该自旋锁可以在多个进程中的线程之间共享;
    • PTHREAD_PROCESS_PRIVATE:私有自旋锁。只有本进程内的线程才能够使用该自旋锁。

加锁和解锁

  • 可以使用 pthread_spin_lock()函数或 pthread_spin_trylock()函数对自旋锁进行加锁,前者在未获取到锁时 一直“自旋”;对于后者,如果未能获取到锁,就立刻返回错误,错误码为 EBUSY。不管以何种方式加锁, 自旋锁都可以使用 pthread_spin_unlock()函数对自旋锁进行解锁
#include <pthread.h>
int pthread_spin_lock(pthread_spinlock_t *lock);
int pthread_spin_trylock(pthread_spinlock_t *lock);
int pthread_spin_unlock(pthread_spinlock_t *lock);
  • 如果自旋锁处于未锁定状态,调用 pthread_spin_lock()会将其锁定(上锁),如果其它线程已经将自旋 锁锁住了,那本次调用将会“自旋”等待;如果试图对同一自旋锁加锁两次必然会导致死锁。
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <unistd.h>
#include <string.h>
static pthread_spinlock_t spin; //定义自旋锁
static int g_count = 0;
static void *new_thread_start(void *arg)
{
int loops = *((int *)arg);
int l_count, j;
for (j = 0; j < loops; j++)
{
pthread_spin_lock(&spin); //自旋锁上锁

l_count = g_count;
l_count++;
g_count = l_count;
pthread_spin_unlock(&spin); //自旋锁解锁
}
return (void *)0;
}
static int loops;
int main(int argc, char *argv[])
{
pthread_t tid1, tid2;
int ret;
/* 获取用户传递的参数 */
if (2 > argc)
loops = 10000000; //没有传递参数默认为 1000 万次
else
loops = atoi(argv[1]);
/* 初始化自旋锁(私有) */
pthread_spin_init(&spin, PTHREAD_PROCESS_PRIVATE);
/* 创建 2 个新线程 */
ret = pthread_create(&tid1, NULL, new_thread_start, &loops);
if (ret)
{
fprintf(stderr, "pthread_create error: %s\n", strerror(ret));
exit(-1);
}
ret = pthread_create(&tid2, NULL, new_thread_start, &loops);
if (ret)
{
fprintf(stderr, "pthread_create error: %s\n", strerror(ret));
exit(-1);
}
/* 等待线程结束 */
ret = pthread_join(tid1, NULL);
if (ret)
{
fprintf(stderr, "pthread_join error: %s\n", strerror(ret));
exit(-1);
}
ret = pthread_join(tid2, NULL);
if (ret)
{
fprintf(stderr, "pthread_join error: %s\n", strerror(ret));
exit(-1);
}
printf("g_count = %d\n", g_count);
/* 销毁自旋锁 */
pthread_spin_destroy(&spin);
exit(0);
}

读写锁

  • 读写锁有如下两个规则:

    • 当读写锁处于写加锁状态时,在这个锁被解锁之前,所有试图对这个锁进行加锁操作(不管是以读 模式加锁还是以写模式加锁)的线程都会被阻塞。
    • 当读写锁处于读加锁状态时,所有试图以读模式对它进行加锁的线程都可以加锁成功;但是任何以 写模式对它进行加锁的线程都会被阻塞,直到所有持有读模式锁的线程释放它们的锁为止。
  • 所以,读写锁非常适合于对共享数据读的次数远大于写的次数的情况。当读写锁处于写模式加锁状态 时,它所保护的数据可以被安全的修改,因为一次只有一个线程可以在写模式下拥有这个锁;当读写锁处于 读模式加锁状态时,它所保护的数据就可以被多个获取读模式锁的线程读取。

初始化

  • 使用宏定义初始化
pthread_rwlock_t rwlock = PTHREAD_RWLOCK_INITIALIZER;
  • 其他方式可以使用 pthread_rwlock_init()函数对其进行初始化,当读写锁不再使用时,需要调用 pthread_rwlock_destroy()函数将其销毁
#include <pthread.h>
int pthread_rwlock_destroy(pthread_rwlock_t *rwlock);
int pthread_rwlock_init(pthread_rwlock_t *rwlock, const pthread_rwlockattr_t *attr);
  • 若将参数 attr 设置为 NULL,则表示将读写锁的属性设置为默认值,在 这种情况下其实就等价于 PTHREAD_RWLOCK_INITIALIZER 这种方式初始化,而不同之处在于,使用宏 不进行错误检查。

上锁和解锁

  • 以读模式对读写锁进行上锁,需要调用 pthread_rwlock_rdlock()函数;以写模式对读写锁进行上锁,需 要调用 pthread_rwlock_wrlock()函数。不管是以何种方式锁住读写锁,均可以调用 pthread_rwlock_unlock()函 数解锁
#include <pthread.h>
int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock);
int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock);
int pthread_rwlock_unlock(pthread_rwlock_t *rwlock);
  • 当读写锁处于写模式加锁状态时,其它线程调用 pthread_rwlock_rdlock()或 pthread_rwlock_wrlock()函数 均会获取锁失败,从而陷入阻塞等待状态;当读写锁处于读模式加锁状态时,其它线程调用 pthread_rwlock_rdlock()函数可以成功获取到锁,如果调用 pthread_rwlock_wrlock()函数则不能获取到锁,从 而陷入阻塞等待状态。

  • 如果线程不希望被阻塞,可以调用 pthread_rwlock_tryrdlock()和 pthread_rwlock_trywrlock()来尝试加锁, 如果不可以获取锁时。这两个函数都会立马返回错误,错误码为 EBUSY。

#include <pthread.h>
int pthread_rwlock_tryrdlock(pthread_rwlock_t *rwlock);
int pthread_rwlock_trywrlock(pthread_rwlock_t *rwlock);

使用例

#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <unistd.h>
#include <string.h>
static pthread_rwlock_t rwlock; //定义读写锁
static int g_count = 0;
static void *read_thread(void *arg)
{
int number = *((int *)arg);
int j;
for (j = 0; j < 10; j++)
{
pthread_rwlock_rdlock(&rwlock); //以读模式获取锁
printf("读线程<%d>, g_count=%d\n", number + 1, g_count);
pthread_rwlock_unlock(&rwlock); //解锁
sleep(1);
}
return (void *)0;
}
static void *write_thread(void *arg)
{
int number = *((int *)arg);
int j;
for (j = 0; j < 10; j++)
{
pthread_rwlock_wrlock(&rwlock); //以写模式获取锁
printf("写线程<%d>, g_count=%d\n", number + 1, g_count += 20);
pthread_rwlock_unlock(&rwlock); //解锁
sleep(1);
}
return (void *)0;
}
static int nums[5] = {0, 1, 2, 3, 4};
int main(int argc, char *argv[])
{
pthread_t tid[10];
int j;
/* 对读写锁进行初始化 */
pthread_rwlock_init(&rwlock, NULL);
/* 创建 5 个读 g_count 变量的线程 */
for (j = 0; j < 5; j++)
pthread_create(&tid[j], NULL, read_thread, &nums[j]);
/* 创建 5 个写 g_count 变量的线程 */
for (j = 0; j < 5; j++)
pthread_create(&tid[j + 5], NULL, write_thread, &nums[j]);
/* 等待线程结束 */
for (j = 0; j < 10; j++)
pthread_join(tid[j], NULL); //回收线程
/* 销毁自旋锁 */
pthread_rwlock_destroy(&rwlock);
exit(0);
}

Linux线程(二)

分离线程

  • 默认情况下,当线程终止时,其它线程可以通过调用 pthread_join()获取其返回状态、回收线程资源,有 时,程序员并不关心线程的返回状态,只是希望系统在线程终止时能够自动回收线程资源并将其移除。在这 种情况下,可以调用 pthread_detach()将指定线程进行分离,也就是分离线程
#include <pthread.h>
int pthread_detach(pthread_t thread);
  • 线程分离自己
  • pthread_detach(pthread_self());
  • 一旦线程处于分离状态,就不能再使用 pthread_join()来获取其终止状态,此过程是不可逆的,一旦处于 分离状态之后便不能再恢复到之前的状态。处于分离状态的线程,当其终止后,能够自动回收线程资源

注册线程清理处理函数

  • 当线程退出时也可以这样做,当线程终止退出时,去执行这样的处理函数, 我们把这个称为线程清理函数
#include <pthread.h>
void pthread_cleanup_push(void (*routine)(void *), void *arg);
void pthread_cleanup_pop(int execute);

当线程执行以下动作时,清理函数栈中的清理函数才会被执行:

  • 线程调用 pthread_exit()退出时;
  • 线程响应取消请求时;
  • 用非 0 参数调用 pthread_cleanup_pop()

线程属性

  • 在 Linux 下,使用 pthread_attr_t 数据类型定义线程的所有属性

线程栈属性

#include <pthread.h>
int pthread_attr_setstack(pthread_attr_t *attr, void *stackaddr, size_t stacksize);
int pthread_attr_getstack(const pthread_attr_t *attr, void **stackaddr, size_t *stacksize);

分离状态属性

#include <pthread.h>
int pthread_attr_setdetachstate(pthread_attr_t *attr, int detachstate);
int pthread_attr_getdetachstate(const pthread_attr_t *attr, int *detachstate);

​ 具体略

线程安全

  • 当我们编写的程序是一个多线程应用程序时,就不得不考虑到线程安全的问题,确保我们编写的程序是 一个线程安全(thread-safe)的多线程应用程序,什么是线程安全以及如何保证线程安全?带着这些问题, 本小节将讨论线程安全相关的话题。

线程栈

  • 进程中创建的每个线程都有自己的栈地址空间,将其称为线程栈。譬如主线程调用 pthread_create()创建 了一个新的线程,那么这个新的线程有它自己独立的栈地址空间、而主线程也有它自己独立的栈地址空间。 在创建一个新的线程时,可以配置线程栈的大小以及起始地址,当然在大部分情况 下,保持默认即可!
  • 然每个线程都有自己的栈地址空间,那么每个线程运行过程中所定义的自动变量(局部变量)都是分 配在自己的线程栈中的,它们不会相互干扰

可重入函数

  • 单线程程序只有一条执行流(一个线程就是一条执行流),贯穿程序始终;而对于 多线程程序而言,同一进程却存在多条独立、并发的执行流。
  • 进程中执行流的数量除了与线程有关之外,与信号处理也有关联。因为信号是异步的,进程可能会在其 运行过程中的任何时间点收到信号,进而跳转、执行信号处理函数,从而在一个单线程进程(包含信号处理) 中形成了两条(即主程序和信号处理函数)独立的执行流。
  • 如果一个函数被同一进程的多个不同的执行流同时调用,每次函数 调用总是能产生正确的结果(或者叫产生预期的结果),把这样的函数就称为可重入函数。实质上也就是该函数被多个执行流并发/并行调用

绝对可重入函数的特点

  • 函数内所使用到的变量均为局部变量,换句话说,该函数内的操作的内存地址均为本地栈地址
  • 函数参数和返回值均是值类型
  • 函数内调用的其它函数也均是绝对可重入函数

很多的 C 库函数有两个版本:可重入版本和不可重入版本,可重入版本函数其名称后面加上了“_r”, 用于表明该函数是一个可重入函数;而不可重入版本函数其名称后面没有“_r”,前面章节内容中也已经遇 到过很多次了,譬如 asctime()/asctime_r()ctime()/ctime_r()localtime()/localtime_r()等。

  • 一个函数具有引用类型的函数,传入了一个指针,并在函数内部读写该指针所指向的内存地址,该函 数是一个可重入函数,但同样需要满足一定的条件;如果多个执行流同时调用该函数时,所传入的指针是共 享变量的地址,那么在这种情况,最终可能得不到预期的结果;因为在这种情况下,函数 func()所读写的便是多个执行流的共享数据,会出现数据不一致的情况,所以是不安全的。
  • 但如果每个执行流所传入的指针是其本地变量(局部变量)对应的地址,那就是没有问题的,所以呢, 这个函数就是一个带条件的可重入函数。

线程安全

  • 一个函数被多个线程(其实也是多个执行流,但是不包括由信号处理函数所产生的执行流)同时调用 时,它总会一直产生正确的结果,把这样的函数称为线程安全函数。线程安全函数包括可重入函数,可重入 函数是线程安全函数的一个真子集,也就是说可重入函数一定是线程安全函数,但线程安全函数不一定是 可重入函数

用来保证线程安全的函数

  • 在多线程编程环境下,有些代码段只需要执行一次
  • 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_t 类型指针,在调用 pthread_once()函数之前,我们需要定义了一 个 pthread_once_t 类型的静态变量,调用 pthread_once()时参数 once_control 指向该变量。通常在定义变量时会使用 PTHREAD_ONCE_INIT 宏对其进行初始化
pthread_once_t once_control = PTHREAD_ONCE_INIT;
  • init_routine:一个函数指针,参数init_routine所指向的函数就是要求只能被执行一次的代码段, pthread_once()函数内部会调用 init_routine(),即使 pthread_once()函数会被多次执行,但它能保证 init_routine() 仅被执行一次。

  • 返回值:调用成功返回 0;失败则返回错误编码以指示错误原因。

  • 如果在一个线程调用 pthread_once()时,另外一个线程也调用了 pthread_once,则该线程将会被阻塞等待,直到第一个完成初始化后返回。换言之,当调用 pthread_once 成功返回时,调用总是能够肯定所有的状态已经初始化完成了。

线程特有数据

  • 线程特有数据也称为线程私有数据,简单点说,就是为每个调用线程分别维护一份变量的副本(copy), 每个线程通过特有数据键(key)访问时,这个特有数据键都会获取到本线程绑定的变量副本。这样就可以 避免变量成为多个线程间的共享数据

  • 线程特有数据的核心思想其实非常简单,就是为每一个调用线程(调用某函数的线程,该函数就是我们 要通过线程特有数据将其实现为线程安全的函数)分配属于该线程的私有数据区,为每个调用线程分别维 护一份变量的副本。

  • pthread_key_create()函数。在为线程分配私有数据区之前,需要调用 pthread_key_create()函数创建一个特有数据键(key)

#include <pthread.h>
int pthread_key_create(pthread_key_t *key, void (*destructor)(void*));
  • key:调用该函数会创建一个特有数据键,并通过参数 key 所指向的缓冲区返回给调用者,参数 key 是 一个 pthread_key_t 类型的指针,可以把 pthread_key_t 称为 key 类型。调用 pthread_key_create()之前,需要 定义一个 pthread_key_t 类型变量,调用 pthread_key_create()时参数 key 指向 pthread_key_t 类型变量。
  • destructor:参数 destructor 是一个函数指针,指向一个自定义的函数
void destructor(void *value)
{
/* code */
}
  • 调用 pthread_key_create()函数允许调用者指定一个自定义的解构函数(类似于 C++中的析构函数),使用参数 destructor 指向该函数;该函数通常用于释放与特有数据键关联的线程私有数据区占用的内存空间, 当使用线程特有数据的线程终止时,destructor()函数会被自动调用。
  • 返回值:成功返回 0;失败将返回一个错误编号以指示错误原因,返回的错误编号其实就是全局变量 errno,可以使用诸如 strerror()函数查看其错误字符串信息。

调用 pthread_key_create()函数创建特有数据键(key)后通常需要为调用线程分配私有数据缓冲区,譬 如通过 malloc()(或类似函数)申请堆内存,每个调用线程分配一次,且只会在线程初次调用此函数时分配。为线程分配私有数据缓冲区之后,通常需要调用 pthread_setspecific()函数,pthread_setspecific()函数其实完成 了这样的操作:首先保存指向线程私有数据缓冲区的指针,并将其与特有数据键以及当前调用线程关联起 来

  • pthread_setspecific()函数
  • 调用 pthread_key_create() 函数创建特有数据键(key)后通常需要为调用线程分配私有数据缓冲区,譬如通过malloc()(或类似函数)申请堆内存,每个调用线程分配一次,且只会在线程初次调用此函数时分配。 为线程分配私有数据缓冲区之后,通常需要调用 pthread_setspecific()函数,pthread_setspecific()函数其实完成了这样的操作:首先保存指向线程私有数据缓冲区的指针,并将其与特有数据键以及当前调用线程关联起来
#include <pthread.h>
int pthread_setspecific(pthread_key_t key, const void *value);
  • key:pthread_key_t 类型变量,参数 key 应赋值为调用 pthread_key_create()函数时创建的特有数据键, 也就是 pthread_key_create()函数的参数 key 所指向的 pthread_key_t变量。
  • value:参数 value 是一个 void 类型的指针,指向由调用者分配的一块内存作为线程的私有数据缓冲 区,当线程终止时,会自动调用参数 key 指定的特有数据键对应的解构函数来释放这一块动态申请的内存 空间。
  • 返回值:调用成功返回 0;失败将返回一个错误编码,可以使用诸如strerror()函数查看其错误字符串信 息。

调用 pthread_setspecific()函数将线程私有数据缓冲区与调用线程以及特有数据键关联之后,便可以使用
pthread_getspecific()函数来获取调用线程的私有数据区了。

  • pthread_getspecific()函数
#include <pthread.h>
void *pthread_getspecific(pthread_key_t key);
  • pthread_getspecific()函数应返回当前调用线程关联到特有数据键的私有数据缓冲区,返回值是一个指针, 指向该缓冲区。如果当前调用线程并没有设置线程私有数据缓冲区与特有数据键进行关联,则返回值应为 NULL,函数中可以利用这一点来判断当前调用线程是否为初次调用该函数,如果是初次调用,则必须为该 线程分配私有数据缓冲区。

如果需要删除一个特有数据键(key)可以使用函数 pthread_key_delete(), pthread_key_delete()函数删除先前由 pthread_key_create()创建的键

  • pthread_key_delete()函数
  • 需要删除一个特有数据键(key)可以使用函数 pthread_key_delete(), pthread_key_delete()函数删除先前由 pthread_key_create()创建的键
#include <pthread.h>
int pthread_key_delete(pthread_key_t key);
  • 调用 pthread_key_delete()函数将释放参数 key 指定的特有数据键,可以供下一次调用 pthread_key_create() 时使用;调用 pthread_key_delete()时,它并不将查当前是否有线程正在使用该键所关联的线程私有数据缓冲 区,所以它并不会触发键的解构函数,也就不会释放键关联的线程私有数据区占用的内存资源,并且调用 pthread_key_delete()后,当线程终止时也不再执行键的解构函数。
  • 调用的条件
    • 所有线程已经释放了私有数据区(显式调用解构函数或线程终止)。
    • 参数 key 指定的特有数据键将不再使用。
#define _GNU_SOURCE
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <pthread.h>
#define MAX_ERROR_LEN 256
static pthread_once_t once = PTHREAD_ONCE_INIT;
static pthread_key_t strerror_key;
static void destructor(void *buf)
{
free(buf); //释放内存
}
static void create_key(void)
{
/* 创建一个键(key),并且绑定键的解构函数 */
if (pthread_key_create(&strerror_key, destructor))
pthread_exit(NULL);
}
/******************************
* 对 strerror 函数重写
* 使其变成为一个线程安全函数
******************************/
static char *strerror(int errnum)
{
char *buf;
/* 创建一个键(只执行一次 create_key) */
if (pthread_once(&once, create_key))
pthread_exit(NULL);
/* 获取 */
buf = pthread_getspecific(strerror_key);
if (NULL == buf)
{ //首次调用 my_strerror 函数,则需给调用线程分配线程私有数据
buf = malloc(MAX_ERROR_LEN); //分配内存
if (NULL == buf)
pthread_exit(NULL);
/* 保存缓冲区地址,与键、线程关联起来 */
if (pthread_setspecific(strerror_key, buf))
pthread_exit(NULL);
}
if (errnum < 0 || errnum >= _sys_nerr || NULL == _sys_errlist[errnum])
snprintf(buf, MAX_ERROR_LEN, "Unknown error %d", errnum);
else
{
strncpy(buf, _sys_errlist[errnum], MAX_ERROR_LEN - 1);
buf[MAX_ERROR_LEN - 1] = '\0'; //终止字符
}
return buf;
}
  • 第一步是调用 pthread_once(),以确保只会执行一次 create_key()函数,而在 create_key()函数中便是调用 pthread_key_create()创建了一个键、并绑定了相应的解构函数 destructor(),解构 函数用于释放与键关联的所有线程私有数据所占的内存空间。
  • 函数 strerror()调用 pthread_getspecific()以获取该调用线程与键相关联的私有数据缓冲区地址,如 果返回为 NULL,则表明该线程是首次调用 strerror()函数,因为函数会调用 malloc()为其分配一个新的私有 数据缓冲区,并调用 pthread_setspecific()来保存缓冲区地址、并与键与该调用线程建立关联。如果 pthread_getspecific()函数的返回值并不等于 NULL,那么该值将指向以存在的私有数据缓冲区,此缓冲区由之前对 strerror()的调用所分配。

使用例

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

pthread_key_t p_key;
int a = 0;
void freeBuf(void * p)
{

}

static void *func(void * str)
{
pthread_setspecific(p_key, &a);
int* ptr = (int*)pthread_getspecific(p_key);
//*ptr += 1;
printf("%s %d\n", str, p_key);

sleep(2);
*ptr += 1;
printf("%s %d\n", str, *ptr);
return NULL;
}

int main()
{
pthread_t pa, pb;

pthread_key_create(&p_key, NULL);

pthread_create(&pa, NULL, func, "thread1:");
sleep(1);
pthread_create(&pb, NULL, func, "thread2:");
pthread_join(pa, NULL);
pthread_join(pb, NULL);

return 0;
}

两个线程使用同一个key访问同一个全局变量,出现了线程不安全情况

image-20220123144524240

假如是使用两个不同的key:

代码更改为

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


int a = 0;
void freeBuf(void * p)
{

}

static void *func(void * str)
{
// pthread_once_t val = PTHREAD_ONCE_INIT;
pthread_key_t p_key;
pthread_key_create(&p_key, NULL);
pthread_setspecific(p_key, &a);
int* ptr = (int*)pthread_getspecific(p_key);
//*ptr += 1;
printf("%s(key) %d\n", str, p_key);

sleep(2);
*ptr += 1;
printf("%s %d\n", str, *ptr);
return NULL;
}

int main()
{
pthread_t pa, pb;



pthread_create(&pa, NULL, func, "thread1:");
sleep(1);
pthread_create(&pb, NULL, func, "thread2:");
pthread_join(pa, NULL);
pthread_join(pb, NULL);

return 0;
}

image-20220123144850517

效果类似

但是对于同一个key,不同的线程set不同的内存位置,再在不同的线程中用同样的key调用get得到的是各自的变量空间

如下

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


int a = 1;
int b = 5;
void freeBuf(void * p)
{

}

static void *func(void * str)
{
pthread_once_t val = PTHREAD_ONCE_INIT;
pthread_key_t p_key;
pthread_key_create(&p_key, NULL);
pthread_setspecific(p_key, str);
char* ptr = pthread_getspecific(p_key);
//*ptr += 1;
printf("%s(key) %d\n", str, p_key);

sleep(2);
int i = ptr[6]-'0';
i += 1;
printf("%s %d\n", ptr, i);
return NULL;
}

int main()
{
pthread_t pa, pb;



pthread_create(&pa, NULL, func, "thread1:");
sleep(1);
pthread_create(&pb, NULL, func, "thread2:");
pthread_join(pa, NULL);
pthread_join(pb, NULL);

return 0;
}

image-20220123145927573

线程局部变量

  • 通常情况下,程序中定义的全局变量是进程中所有线程共享的,所有线程都可以访问这些全局变量;而 线程局部存储在定义全局或静态变量时,使用__thread 修饰符修饰变量,此时,每个线程都会拥有一份对该 变量的拷贝。线程局部存储中的变量将一直存在,直至线程终止,届时会自动释放这一存储

  • 要创建线程局部变量,只需简单地在全 局或静态变量的声明中包含__thread 修饰符即可!

static __thread char buf[512];

注意:

  • 如果变量声明中使用了关键字 static 或 extern,那么关键字__thread 必须紧随其后。
  • 与一般的全局或静态变量申明一眼,线程局部变量在申明时可设置一个初始值。
  • 可以使用 C 语言取值操作符(&)来获取线程局部变量的地址。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <pthread.h>
static __thread char buf[100];
static void *thread_start(void *arg)
{
strcpy(buf, "Child Thread\n");
printf("子线程: buf (%p) = %s", buf, buf);
pthread_exit(NULL);
}
int main(int argc, char *argv[])
{
pthread_t tid;
int ret;
strcpy(buf, "Main Thread\n");
/* 创建子线程 */
if (ret = pthread_create(&tid, NULL, thread_start, NULL))
{
fprintf(stderr, "pthread_create error: %d\n", ret);
exit(-1);
}
/* 等待回收子线程 */
if (ret = pthread_join(tid, NULL))
{
fprintf(stderr, "pthread_join error: %d\n", ret);
exit(-1);
}
printf("主线程: buf (%p) = %s", buf, buf);
exit(0);
}

image-20220123185312283

可见主线程和子线程的buf不是同一个东西

多线程信号处理

信号模型在一些方面是属于进程层面(由进程中的所有线程线程共享)的,而在另一些方面是属于单个 线程层面的

  • 信号的系统默认行为是属于进程层面。每一个信号都有其对应的系统默认动作, 当进程中的任一线程收到任何一个未经处理(忽略或捕获)的信号时,会执行该信号的默认操作, 信号的默认操作通常是停止或终止进程。

  • 信号处理函数属于进程层面。进程中的所有线程共享程序中所注册的信号处理函数;

  • 信号的发送既可针对整个进程,也可针对某个特定的线程。在满足以下三个条件中的任意一个时, 信号的发送针对的是某个线程

    • 产生了硬件异常相关信号,譬如 SIGBUS、SIGFPE、SIGILL 和 SIGSEGV 信号;这些硬件异 常信号在某个线程执行指令的过程中产生,也就是说这些硬件异常信号是由某个线程所引起; 那么在这种情况下,系统会将信号发送给该线程。
    • 当线程试图对已断开的管道进行写操作时所产生的 SIGPIPE 信号;
    • 由函数 pthread_kill()或 pthread_sigqueue()所发出的信号,稍后介绍这两个函数;这些函数允许 线程向同一进程下的其它线程发送一个指定的信号。
  • 当一个多线程进程接收到一个信号时,且该信号绑定了信号处理函数时,内核会任选一个线程来接 收这个信号,意味着由该线程接收信号并调用信号处理函数对其进行处理,并不是每个线程都会接 收到该信号并调用信号处理函数

  • 信号掩码其实是属于线程层面的,也就是说信号掩码是针对每个线程而言。8.9 小节向大家介绍了 信号掩码的概念,并介绍了 sigprocmask()函数,通过 sigprocmask()可以设置进程的信号掩码,事实 上,信号掩码是并不是针对整个进程来说,而是针对线程,对于一个多线程应用程序来说,并不存 在一个作用于整个进程范围内的信号掩码(管理进程中的所有线程);那么在多线程环境下,各个 线程可以调用 pthread_sigmask()函数来设置它们各自的信号掩码,譬如设置线程可以接收哪些信号、 不接收哪些信号,各线程可独立阻止或放行各种信号。

  • 针对整个进程所挂起的信号,以及针对每个线程所挂起的信号,内核都会分别进行维护、记录。 8.11.1 小节介绍到,调用 sigpending()会返回进程中所有被挂起的信号,事实上,sigpending()会返 回针对整个进程所挂起的信号,以及针对每个线程所挂起的信号的并集。

  • 其他内容略

C语言函数指针

  • 函数指针是指向函数的指针变量
<返回值类型> (*函数指针名称)(<输入参数类型>,...)
  • 回调函数使用例:
void populate_array(int *array, size_t arraySize, int (*getNextValue)(void))
{
for (size_t i=0; i<arraySize; i++)
array[i] = getNextValue();
}

传参:

populate_array(myarray, 10, getNextRandomValue);

Linux线程(一)

一些注意事项

  • Linux中每个线程都有单独的进程ID,inux中每个线程都有单独的进程ID。在Linux中,线程其实是通过轻量级进程(LWP)实现的,因此Linux中每个线程都是一个进程,都拥有一个PID。换句话说,操作系统原理中的线程,对应的其实是Linux中的进程
  • Linux的线程中,假如主线程(也就是main函数)执行结束退出(比如exit()或者return 0),会导致整个进程所有线程被强制停止
  • 即使是正在执行尚且没有结束的线程也会被停止

线程概念

  • 线程是参与系统调度的最小单位。它被包含在进程之中,是进程中的实际运行单位。一个线程指的是进 程中一个单一顺序的控制流(或者说是执行路线、执行流),一个进程中可以创建多个线程,多个线程实现 并发运行,每个线程执行不同的任务。譬如某应用程序设计了两个需要并发运行的任务 task1 和 task2,可将 两个不同的任务分别放置在两个线程中。

关于线程的更深层次的理解

  • 参考
  • 对于进程来说,相同的地址(同一个虚拟地址)在不同的进程中,反复使用而不冲突。原因是他们虽虚拟址一样,但是页目录、页表、物理页面各不相同。相同的虚拟址,映射到不同的物理页面内存单元,最终访问不同的物理页面。
    但!线程不同!两个线程具有各自独立的PCB,但共享同一个页目录,也就共享同一个页表和物理页面。所以两个PCB共享一个地址空间。
  • 实际上,无论是创建进程的fork,还是创建线程的pthread_create,底层实现都是调用同一个内核函数clone
  • 如果复制对方的地址空间,那么就产出一个“进程”;如果共享对方的地址空间,就产生一个“线程”
  • Linux内核是不区分进程和线程的。只在用户层面上进行区分。所以,线程所有操作函数 pthread_* 是库函数,而非系统调用
  • 进程:独立地址空间,拥有PCB
  • 线程:也有PCB,但没有独立的地址空间

线程的创建

  • 当一个程序启动时,就有一个进程被操作系统(OS)创建,与此同时一个线程也立刻运行,该线程通 常叫做程序的主线程(Main Thread),因为它是程序一开始时就运行的线程。应用程序都是以 main()做为 入口开始运行的,所以 main()函数就是主线程的入口函数,main()函数所执行的任务就是主线程需要执行的 任务。
  • 所以由此可知,任何一个进程都包含一个主线程,只有主线程的进程称为单线程进程,譬如前面章节内 容中所编写的所有应用程序都是单线程程序,它们只有主线程;既然有单线程进程,那自然就存在多线程进 程,所谓多线程指的是除了主线程以外,还包含其它的线程,其它线程通常由主线程来创建(调用 pthread_create 创建一个新的线程),那么创建的新线程就是主线程的子线程。
    • 其它新的线程(也就是子线程)是由主线程创建的;
    • 主线程通常会在最后结束运行,执行各种清理工作,譬如回收各个子线程。

线程vs进程

  • 进程间切换开销大。多个进程同时运行(指宏观上同时运行,无特别说明,均指宏观上),微观上 依然是轮流切换运行,进程间切换开销远大于同一进程的多个线程间切换的开销,通常对于一些中 小型应用程序来说不划算。
  • 进程间通信较为麻烦。每个进程都在各自的地址空间中、相互独立、隔离,处在于不同的地址空间 中,因此相互通信较为麻烦,在上一章节给大家有所介绍。
  • 同一进程的多个线程间切换开销比较小。
  • 同一进程的多个线程间通信容易。它们共享了进程的地址空间,所以它们都是在同一个地址空间 中,通信容易。
  • 线程创建的速度远大于进程创建的速度。
  • 多线程在多核处理器上更有优势!

并发和并行

  • 并行指的是可以并排/并列执行多个任务,这样的系统,它通常有多个执行单 元,所以可以实现并行运行,譬如并行运行 task1、task2、task3。

  • image-20220121235908536

  • 并行运行并不一定要同时开始运行、同时结束运行

  • 并发强调的是一种时分复用,与串行的区别在于,它不必等待上一个任务完成之后 在做下一个任务,可以打断当前执行的任务切换执行下一个任何,这就是时分复用。在同一个执行单元上, 将时间分解成不同的片段(时间片),每个任务执行一段时间,时间一到则切换执行下一个任务,依次这样 轮训(交叉/交替执行),这就是并发运行。

  • image-20220122000512605

  • 你吃饭吃到一半,电话来了,你一直到吃完了以后才去接电话,这就说明你不支持并发也不支持并 行,仅仅只是串行

  • 你吃饭吃到一半,电话来了,你停下吃饭去接了电话,电话接完后继续吃饭,这说明你支持并发

  • 你吃饭吃到一半,电话来了,你一边打电话一边吃饭,这说明你支持并行

  • 计算机处理器运行速度是非常快的,在单个处理核心虽然以并发方式运行着系统中的线程(微观上交替 /交叉方式运行不同的线程),但在宏观上所表现出来的效果是同时运行着系统中的所有线程,因为处理器 的运算速度太快了,交替轮训一次所花费的时间在宏观上几乎是可以忽略不计的,所以表示出来的效果就 是同时运行着所有线程。

进程ID

  • 每个线程也有其对应的标识,称为线程 ID。进程 ID 在整个系统 中是唯一的,但线程 ID 不同,线程 ID 只有在它所属的进程上下文中才有意义

  • 一个线程可通过库函数 pthread_self()来获取自己的线程 ID

#include <pthread.h>
pthread_t pthread_self(void);
  • 该函数调用总是成功,返回当前线程的线程 ID
  • 可以使用 pthread_equal()函数来检查两个线程 ID 是否相等
#include <pthread.h>
int pthread_equal(pthread_t t1, pthread_t t2);
//如果两个线程 ID t1 和 t2 相等,则 pthread_equal()返回一个非零值;否则返回 0。

在 Linux 系统中,使 用无符号长整型(unsigned long int)来表示 pthread_t 数据类型

创建线程

  • 主线程可以使用库函数 pthread_create()负责创建一个新的线程
#include <pthread.h>
int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine) (void *), void *arg);
  • 注意,这个线程本质上是通过库函数调用系统的clone实现的,实际上就是创建了一个新的进程(类似vfork),但是这个进程的所有数据资源都直接指向创建它的进程的数据资源,导致二者实际使用起来是共享变量和数据等等

    • 因此所谓的线程也是进程,只不过是与创建它的进程共享资源的进程
  • thread:pthread_t 类型指针,当 pthread_create()成功返回时,新创建的线程的线程 ID 会保存在参数 thread 所指向的内存中,后续的线程相关函数会使用该标识来引用此线程。

  • attr:pthread_attr_t 类型指针,指向 pthread_attr_t 类型的缓冲区,pthread_attr_t 数据类型定义了线程的 各种属性,关于线程属性将会在 11.8 小节介绍。如果将参数 attr 设置为 NULL,那么表示将线程的所有属 性设置为默认值,以此创建新线程。

  • start_routine:参数 start_routine 是一个函数指针,指向一个函数,新创建的线程从 start_routine()函数 开始运行,该函数返回值类型为void *,并且该函数的参数只有一个void *,其实这个参数就是pthread_create() 函数的第四个参数 arg。如果需要向 start_routine()传递的参数有一个以上,那么需要把这些参数放到一个结 构体中,然后把这个结构体对象的地址作为 arg 参数传入。

  • arg:传递给 start_routine()函数的参数。一般情况下,需要将 arg 指向一个全局或堆变量,意思就是说 在线程的生命周期中,该 arg 指向的对象必须存在,否则如果线程中访问了该对象将会出现错误。当然也可 将参数 arg 设置为 NULL,表示不需要传入参数给 start_routine()函数。

  • 返回值:成功返回 0;失败时将返回一个错误号,并且参数 thread 指向的内容是不确定的。

  • 线程创建成功,新线程就会加入到系统调度队列中,获取到 CPU 之后就会立马从 start_routine()函数开 始运行该线程的任务;调用 pthread_create()函数后,通常我们无法确定系统接着会调度哪一个线程来使用 CPU 资源

  • 在编译含有pthread的库函数的文件的时候,需要通过gcc的-l选项指定链接库,比如

gcc -o 文件名 文件名.c -lpthread

应用举例:

#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <unistd.h>
static void *new_thread_start(void *arg)
{
printf("新线程: 进程 ID<%d> 线程 ID<%lu>\n", getpid(), pthread_self());
return (void *)0;
}
int main(void)
{
pthread_t tid;
int ret;
ret = pthread_create(&tid, NULL, new_thread_start, NULL);
if (ret)
{
fprintf(stderr, "Error: %s\n", strerror(ret));
exit(-1);
}
printf("主线程: 进程 ID<%d> 线程 ID<%lu>\n", getpid(), pthread_self());
sleep(1);
exit(0);
}

输出

image-20220122104651258

  • 主线程休眠了 1 秒钟,原因在于,如果主线程不进行休眠,它就可能会立马退出,这样可能会导致新创 建的线程还没有机会运行,整个进程就结束了

线程终止

  • 线程的 start 函数执行 return 语句并返回指定值,返回值就是线程的退出码;
  • 线程调用 pthread_exit()函数;
  • 调用 pthread_cancel()取消线程(将在 11.6 小节介绍);

如果进程中的任意线程调用 exit()_exit()或者_Exit(),那么将会导致整个进程终止,这里需要注意!

  • pthread_exit()函数将终止调用它的线程
#include <pthread.h>
void pthread_exit(void *retval);
  • 参数 retval 的数据类型为 void *,指定了线程的返回值、也就是线程的退出码,该返回值可由另一个线 程通过调用 pthread_join()来获取;同理,如果线程是在 start 函数中执行 return 语句终止,那么 return 的返 回值也是可以通过 pthread_join()来获取的。

  • 调用 pthread_exit()相当于在线程的 start 函数中执行 return 语句,不同之处在于,可在线程 start 函数所 调用的任意函数中调用 pthread_exit()来终止线程。如果主线程调用了 pthread_exit(),那么主线程也会终止, 但其它线程依然正常运行,直到进程中的所有线程终止才会使得进程终止

#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <unistd.h>
static void *new_thread_start(void *arg)
{
sleep(1);
printf("新线程: 进程 ID<%d> 线程 ID<%lu>\n", getpid(), pthread_self());
pthread_exit(NULL);
}
int main(void)
{
pthread_t tid;
int ret;
ret = pthread_create(&tid, NULL, new_thread_start, NULL);
if (ret)
{
fprintf(stderr, "Error: %s\n", strerror(ret));
exit(-1);
}
printf("主线程: 进程 ID<%d> 线程 ID<%lu>\n", getpid(), pthread_self());

pthread_exit(NULL);
}

  • 注意,将上一个程序中的所有return都改成pthread_exit(NULL)之后,程序会话并没有在主线程停止之后停止,而是等待子线程停止之后才停止

输出为

image-20220122110519433

回收线程

  • 调用 pthread_join()函数来阻塞等待线程的终止, 并获取线程的退出码,回收线程资源(类似于多进程中的wait()函数)
#include <pthread.h>
int pthread_join(pthread_t thread, void **retval);
  • thread:pthread_join()等待指定线程的终止,通过参数 thread(线程 ID)指定需要等待的线程;

  • retval:如果参数 retval 不为 NULL,则 pthread_join()将目标线程的退出状态(即目标线程通过 pthread_exit()退出时指定的返回值或者在线程 start 函数中执行 return 语句对应的返回值)复制到*retval 所指 向的内存区域;如果目标线程被 pthread_cancel()取消,则将 PTHREAD_CANCELED 放在*retval 中。如果对 目标线程的终止状态不感兴趣,则可将参数 retval 设置为 NULL。

  • 返回值:成功返回 0;失败将返回错误码。

  • 调用 pthread_join()函数将会以阻塞的形式等待指定的线程终止,如果该线程已经终止,则 pthread_join() 立刻返回。如果多个线程同时尝试调用 pthread_join()等待指定线程的终止,那么结果将是不确定的。

  • 若线程并未分离(detached,将在 11.6.1 小节介绍),则必须使用 pthread_join()来等待线程终止,回收 线程资源;如果线程终止后,其它线程没有调用 pthread_join()函数来回收该线程,那么该线程将变成僵尸线程,与僵尸进程的概念相类似;同样,僵尸线程除了浪费系统资源外,若僵尸线程积累过多,那么会导致应 用程序无法创建新的线程。

  • 如果进程中存在着僵尸线程并未得到回收,当进程终止之后,进程会被其父进程回收,所以僵尸 线程同样也会被回收。

进程还具有以下特点

  • 线程之间关系是对等的。进程中的任意线程均可调用 pthread_join()函数来等待另一个线程的终止。 譬如,如果线程 A 创建了线程 B,线程 B 再创建线程 C,那么线程 A 可以调用 pthread_join()等待 线程 C 的终止,线程 C 也可以调用 pthread_join()等待线程 A 的终止;这与进程间层次关系不同, 父进程如果使用 fork()创建了子进程,那么它也是唯一能够对子进程调用 wait()的进程,线程之间 不存在这样的关系

  • 不能以非阻塞的方式调用 pthread_join()。对于进程,调用 waitpid()既可以实现阻塞方式等待、也可 以实现非阻塞方式等待。

取消线程

  • 有时候,在程序设计需求当中,需要向一个线程发送一个请求,要求它立刻退出,我们把这种操作称为 取消线程,也就是向指定的线程发送一个请求,要求其立刻终止、退出。譬如,一组线程正在执行一个运算, 一旦某个线程检测到错误发生,需要其它线程退出,取消线程这项功能就派上用场了。
  • 调用 pthread_cancel()库函数向一个指定的线程发送取消请求
#include <pthread.h>
int pthread_cancel(pthread_t thread);
  • 发出取消请求之后,函数 pthread_cancel()立即返回,不会等待目标线程的退出。默认情况下,目标线程 也会立刻退出
  • 线程可以设置自己不被取消或者控制如何被取消

使用例:

#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <unistd.h>
static void *new_thread_start(void *arg)
{
printf("新线程--running\n");
for (;;)
sleep(1);
return (void *)0;
}
int main(void)
{
pthread_t tid;
void *tret;
int ret;
/* 创建新线程 */
ret = pthread_create(&tid, NULL, new_thread_start, NULL);
if (ret)
{
fprintf(stderr, "pthread_create error: %s\n", strerror(ret));
exit(-1);
}
sleep(1);
/* 向新线程发送取消请求 */
ret = pthread_cancel(tid);
if (ret)
{
fprintf(stderr, "pthread_cancel error: %s\n", strerror(ret));
exit(-1);
}
/* 等待新线程终止 */
ret = pthread_join(tid, &tret);
if (ret)
{
fprintf(stderr, "pthread_join error: %s\n", strerror(ret));
exit(-1);
}
printf("新线程终止, code=%ld\n", (long)tret);
exit(0);
}

image-20220122120358618

  • 由打印结果可知,当主线程发送取消请求之后,新线程便退出了,而且退出码为-1,也就是 PTHREAD_CANCELED

线程控制自己被取消的时候的行为

  • 默认情况下,线程是响应其它线程发送过来的取消请求的,响应请求然后退出线程。当然,线程可以选 择不被取消或者控制如何被取消,通过 pthread_setcancelstate()和 pthread_setcanceltype()来设置线程的取消性 状态和类型。
#include <pthread.h>
int pthread_setcancelstate(int state, int *oldstate);
int pthread_setcanceltype(int type, int *oldtype);

参数

  • PTHREAD_CANCEL_ENABLE:线程可以取消,这是新创建的线程取消性状态的默认值,所以 新建线程以及主线程默认都是可以取消的。

  • PTHREAD_CANCEL_DISABLE:线程不可被取消,如果此类线程接收到取消请求,则会将请求 挂起,直至线程的取消性状态变为 PTHREAD_CANCEL_ENABLE。

  • pthread_setcanceltype()函数执行的设置取消性类型和获取旧类型操作,这两步是一个原子操作。

  • 参数 type 必须是以下值之一:

    • PTHREAD_CANCEL_DEFERRED:取消请求到来时,线程还是继续运行,取消请求被挂起,直 到线程到达某个取消点(cancellation point,将在 11.6.3 小节介绍)为止,这是所有新建线程包括 主线程默认的取消性类型。
    • PTHREAD_CANCEL_ASYNCHRONOUS:可能会在任何时间点(也许是立即取消,但不一定) 取消线程,这种取消性类型应用场景很少,不再介绍!
  • 取消点:

    • 取消点其实就是一系列函数,当执行到这些函数的时候,才会真正响应取消请 求,这些函数就是取消点;在没有出现取消点时,取消请求是无法得到处理的,究其原因在于系统认为,但 没有到达取消点时,线程此时正在执行的工作是不能被停止的,正在执行关键代码,此时终止线程将可能会 导致出现意想不到的异常发生。
  • 检测线程的可取消性

    • pthread_testcancel(void);,头文件同上

Linux进程间通信简介

管道和 FIFO

  • 管道是 UNIX 系统上最古老的 IPC 方法,它在 20 世纪 70 年代早期 UNIX 的第三个版本上就出现了。 把一个进程连接到另一个进程的数据流称为管道,管道被抽象成一个文件,5.1 小节曾提及过管道文件(pipe) 这样一种文件类型。
    • 普通管道 pipe:通常有两种限制,一是单工,数据只能单向传输;二是只能在父子或者兄弟进程间 使用;
    • 流管道 s_pipe:去除了普通管道的第一种限制,为半双工,可以双向传输;只能在父子或兄弟进程 间使用;
    • 有名管道 name_pipe(FIFO):去除了普通管道的第二种限制,并且允许在不相关(不是父子或兄 弟关系)的进程间进行通讯。
  • 普通管道可用于具有亲缘关系的进程间通信,并且数据只能单向传输,如果要实现双向传输,则必须要 使用两个管道;而流管道去除了普通管道的第一种限制,可以半双工的方式实现双向传输,但也只能在具有 亲缘关系的进程间通信;而有名管道(FIFO)则同时突破了普通管道的两种限制,即可实现双向传输、又能 在非亲缘关系的进程间通信。

信号

  • 通知接收信号的进程有某种事件发生,所以可 用于进程间通信;除了用于进程间通信之外,进程还可以发送信号给进程本身。

消息队列

  • 消息队列是消息的链表,存放在内核中并由消息队列标识符标识,消息队列克服了信号传递信息少、管 道只能承载无格式字节流以及缓冲区大小受限等缺陷。消息队列包括 POSIX 消息队列和 System V 消息队 列。
  • 消息队列是 UNIX 下不同进程之间实现共享资源的一种机制,UNIX 允许不同进程将格式化的数据流以 消息队列形式发送给任意进程,有足够权限的进程可以向队列中添加消息,被赋予读权限的进程则可以读 走队列中的消息。

信号量

  • 信号量是一个计数器,与其它进程间通信方式不大相同,它主要用于控制多个进程间或一个进程内的多 个线程间对共享资源的访问,相当于内存中的标志,进程可以根据它判定是否能够访问某些共享资源,同 时,进程也可以修改该标志,除了用于共享资源的访问控制外,还可用于进程同步。
  • 它常作为一种锁机制,防止某进程在访问资源时其它进程也访问该资源,因此,主要作为进程间以及同 一个进程内不同线程之间的同步手段。Linux 提供了一组精心设计的信号量接口来对信号量进行操作,它们 声明在头文件 sys/sem.h 中。

共享内存

  • 共享内存就是映射一段能被其它进程所访问的内存,这段共享内存由一个进程创建,但其它的多个进程 都可以访问,使得多个进程可以访问同一块内存空间。共享内存是最快的 IPC 方式,它是针对其它进程间 通信方式运行效率低而专门设计的,它往往与其它通信机制,譬如结合信号量来使用,以实现进程间的同步 和通信。

套接字(Socket)

  • Socket 是一种 IPC 方法,是基于网络的 IPC 方法,允许位于同一主机(计算机)或使用网络连接起来 的不同主机上的应用程序之间交换数据,说白了就是网络通信,在提高篇章节内容中将会向大家介绍 Linux 系统下的网络编程。
    • 各个应用程序创建一个 socket。socket 是一个允许通信的“设备”,两个应用程序都需要用到它。
    • 服务器将自己的 socket 绑定到一个众所周知的地址(名称)上使得客户端能够定位到它的位置。