由于上部分按照视频所学部分内容可能概念并不清晰、知识体系结构并不明确等,所以本部分按照《Linux高性能服务器编程》进行整理

一、Linux网络编程基础API

我们所说的socket实际上有很多含义,在不同的上下文中具有不同的含义;socket地址API、socket基础API和网络信息API是Linux网络中的基础API;socket系统调用指的是调用socket函数用于创建一个socket文件描述符,我们在网络编程中认为的插头socket就是这个被创建出来的socket文件描述符

1.创建socket

  1. //使用如下的socket系统调用创建一个socket文件描述符号
  2. #include <sys/types.h>
  3. #include <sys/socket.h>
  4. int socket(int domain ,int type , int protocol);
  5. /*
  6. @domain:使用哪个底层协议族
  7. PF_INET (IPV4)
  8. PF_INET6(IPV6)
  9. PF_UNIX(本地通信)
  10. @type:指定服务类型
  11. SOCK_STREAM(流服务,适用于TCP)
  12. SOCK_UGRAM/SOCK_DGRAM(数据报服务,适用于UDP)
  13. @protocol:附加协议
  14. 一般都设置为0
  15. */

2.命名socket

创建文件描述符时,指定了协议族/地址族,但是没有指定使用该地址族当中的具体socket地址;服务器程序中我们通常需要将一个socket文件描述符与socket地址绑定(称为给socket命名),客户端则通常采用匿名方式(即使用操作系统随机分配的socket地址)

  1. #include <sys/types.h>
  2. #include <sys/socket.h>
  3. int bind(int sockfd,const struct sockaddr *my_addr,socklen_t addrlen);
  4. /*
  5. bind函数将 通用socket地址类型sockaddr 的指针my_addr指向的socket地址分配给未命名的
  6. sockfd文件描述符
  7. addrlen参数表示该socket地址的长度
  8. */

3.监听socket

socket命名后还不能立刻接收客户端的连接,需要使用下面的系统调用来创建一个监听队列以存放待处理的客户连接
监听队列的长度如果超过backlog则服务器不再接收新的客户连接,且客户端会受到错误信息

  1. #include <sys/socket.h>
  2. int listen(int sockfd,int backlog);
  3. /*
  4. @sockfd:被监听的文件描述符
  5. @backlog:内核监听队列的最大长度即允许通信连接的主机个数,一般设置为5、10
  6. */

4.接收连接

下面的系统调用从listen监听队列中接受一个连接
该函数成功会返回一个新的连接socket文件描述符,用于唯一地标识TCP服务器和客户端之间的该被接收的连接,服务器通过读写该socket来与客户端进行通信

  1. #include <sys/types.h>
  2. #include <sys/socket.h>
  3. int accept (int sockfd,struct sockaddr *addr ,socklen_t *addrlen);
  4. /*
  5. @sockfd:执行listen系统调用的监听socket文件描述符
  6. @addr:获取被接受的远端socket地址
  7. @addrlen:指出上述远端socket地址的长度
  8. */

5.发起连接

服务器通过listen调用来被动接收连接,客户端通过connect调用来主动发起连接
需要注意的是connect函数不会像客户端的accept函数一样返回新的文件描述符,在客户端,一旦成功与服务器建立连接则sockfd就唯一的标识了该连接,客户端通过读写该sockfd与服务器进行通信

  1. #include <sys/types.h>
  2. #include <sys/socket.h>
  3. int connect (int sockfd,const struct sockaddr *serv_addr,socklen_t addrlen);
  4. /*
  5. @sockfd:客户端调用socket函数返回得到的文件描述符
  6. @sockaddr:服务器监听的socket地址
  7. @addrlen:该地址的长度
  8. */

6.关闭连接

关闭一个连接实际上就是关闭该连接对应的socket

  1. #include<unistd.h>
  2. int close(int fd);
  3. /*
  4. @fd:待关闭的socket
  5. */
  1. #incude <sys/socket.h>
  2. int shutdown(int sockfd,int howto);
  3. /*
  4. @socket:待关闭的socket
  5. @howto:决定了shutdown的行为(分别关闭socket上的读或者写或同时关闭读写)
  6. */

7.数据读写

对文件的读写操作read和write同样适用于socket,而socket编程接口也有专门用于socket读写的系统调用

7.1 UDP数据读写

这两个系统调用也可用于面向连接,只需要把最后两个参数设置为NULL即可

  1. #include<sys/types.h>//基本系统数据类型,是Unix/Linux系统的基本系统数据类型的头文件,含有size_t,time_t,pid_t等类型
  2. #include<sys/socket.h>
  3. ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,
  4. struct sockaddr *src_addr, socklen_t *addrlen);
  5. /*
  6. @sockfd:文件描述符,socket的返回值
  7. @buf:保存接收的数据
  8. @len:buf的长度
  9. @flags:标志位
  10. 0 阻塞
  11. MSG_DONTWAIT 非阻塞
  12. @src_addr:源的网络信息结构体,因为UDP没有连接的概念所以我们每次读取都需要获取发送端的socket地址
  13. @addrlen:src_addr的长度
  14. recv读取sockfd上的数据,可能我们需要多次调用recv才能读取到完整的数据
  15. */
  1. #include <sys/types.h>
  2. #include <sys/socket.h>
  3. ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,
  4. const struct sockaddr *dest_addr, socklen_t addrlen);
  5. /*
  6. @sockfd:套接字即文件描述符,socket的返回值
  7. @buf:要发送的数据/发送数据缓冲区
  8. @len:buf的长度
  9. @flags:标志位
  10. 0 阻塞
  11. MSG_DONTWAIT 非阻塞
  12. @dest_addr:目的网络信息结构体(需要自己指定要给谁发送)
  13. @addrlen:dest_addr的长度
  14. */

7.2 TCP数据读写

8.常见头文件

  1. sys/types.h:数据类型定义
  2. sys/socket.h:提供socket函数及数据结构
  3. netinet/in.h:定义数据结构sockaddr_in
  4. arpa/inet.h:提供IP地址转换函数
  5. netdb.h:提供设置及获取域名的函数
  6. sys/ioctl.h:提供对I/O控制的函数
  7. sys/poll.h:提供socket等待测试机制的函数
  8. unistd.h:提供通用的文件、目录、程序及进程操作的函数
  9. errno.h:提供错误号errno的定义,用于错误处理
  10. fcntl.h:提供对文件控制的函数
  11. time.h:提供有关时间的函数
  12. crypt.h:提供使用DES加密算法的加密函数
  13. pwd.h:提供对/etc/passwd文件访问的函数
  14. shadow.h:提供对/etc/shadow文件访问的函数
  15. pthread.h:提供多线程操作的函数
  16. signal.h:提供对信号操作的函数
  17. sys/wait.hsys/ipc.hsys/shm.h:提供进程等待、进程间通讯(IPC)及共享内存的函数

二、Linux多进程

我们在这一小节以及后面的小节会适当补充一些Linux系统下与C/C++相关的知识点,因为在Windows下的C/C++实际上与Linux下的有很大区别(尽管可能现在不觉得完全是因为还没涉及到differrent的领域)。并且这些知识点与网络编程息息相关。

1.数据在内存中的分区

1.1 C++变量

C++有三种管理数据内存的方式:自动存储(栈区)、静态存储(栈区)和动态存储(内存池、自由存储空间或堆区),C++11中新增了第四种类型——线程存储(属于并行编程的内容)

  • 自动存储:在函数内部定义的常规变量(包括函数参数)称为自动变量,使用自动存储空间。自动变量是一个局部变量,作用域为包含它的代码块。生命周期为所属函数调用期间。
  • 静态存储:全局变量或static关键字声明的变量称为静态变量,使用静态存储空间。生命周期为整个程序执行期间。
  • 动态存储:new运算符分配的内存将一直存在,直到使用delete运算符释放或程序结束。
  • 线程存储:使用关键字thread_local声明的变量,生命周期和所属线程一样长。

    1.2 Linux进程

    Linux系统中一个进程在内存里分为三部分的数据,就是“代码段”、”堆栈段”和”数据段”
    系统如果同时运行多个相同的程序,它们的“代码段”是相同的,“堆栈段”和“数据段”是不同的(相同的程序,处理的数据不同)

  • “代码段”,顾名思义,就是存放了程序代码

  • “堆栈段”存放的就是程序的返回地址、程序的参数以及程序的局部变量
  • “数据段”存放程序的全局变量,常数以及动态数据分配的数据空间(比如用new函数分配的空间)。

注意:我们在本章关注的是Linux系统中的进程在内存中的分区,并不会关心C++的变量在内存中的分区;两者的分区的意义是不一样的,尽管可能会有部分概念的相似之处。

2.进程概念

进程这个概念是针对系统而不是针对程序员的,对程序员来说,我们面向的概念是程序,当输入指令执行一个程序的时候,对系统而言,它将启动一个进程。
进程就是正在内存中运行中的程序。

UID    :启动进程的操作系统用户。
PID    :进程编号。
PPID   :进程的父进程的编号。
C          :CPU使用的资源百分比。
STIME :进程启动时间。
TTY     :进程所属的终端。
TIME   :使用掉的CPU时间。
CMD   :执行的是什么指令。
//getpid库函数的功能是获取本程序运行时进程的编号,函数没有参数,返回值是进程的编号

注意两个细节:
1)进程的编号是系统动态分配的,相同的程序在不同的时间执行,进程的编号是不同的。
2)进程的编号会循环使用,但是,在同一时间,进程的编号是唯一的,也就是说,不管任何时间,系统不可能存在两个编号相同的进程。

3.多进程

补充知识:
(1)多用户操作系统
多用户操作系统一般来讲就是分时操作系统,若干终端联机使用同一台电脑。终端机只是一个输入输出设备(比如只有键盘、显示器、打印机),没有运算与存储能力。每个用户通过各自的终端机使用同一台计算机,计算机按设定好的时间片轮转为各个终端服务,使得每个用户感觉只有自己一人在使用计算机。多任务操作系统具有很强的交互性能。
(2)多任务操作系统
假如用户在同一时间可以运行多个应用程序(每个应用程序被称作一个任务),则这样的操作系统被称为多任务操作系统。如果一个用户在同一时间只能运行一个应用程序,则对应的操作系统称为单任务操作系统。而多任务也就是同时做多件事比如一边听歌一边玩游戏,比如windowsxp就是多任务操作系统

3.1 fork函数

在Linux环境下可以使用fork函数来创建一个进程,however,在Windows系统下根本调用不了这个函数,要说原因,对于UNIX来说它一出生就是多用户的系统,所以它的所有进程都共有一个最原始的父进程init.而windows生下来时是个单用户系统(DOS),不存在这样的概念(尽管现在新版本的Windows基本都是多用户操作系统).所以fork这个函数是UNIX下特有的
如果Win硬要模似多进程,使用CreateProcess()还不如用CreateThread()更接近实际情况,把主thread中的所有公共变量都塞入一个结构/类的,带入新的thread中,这样可以大致完成”复制自身”的要求

int main()
{
  printf("本程序的进程编号是:%d\n",getpid());

  int ipid=fork();//使用fork函数创建一个新的进程,在父进程中,返回值是子进程编号,在子进程中,返回值是0
 //程序员可以通过fork的返回值来区分父进程和子进程,然后再执行不同的代码
  sleep(1);       // sleep等待进程的生成。

  printf("pid=%d\n",ipid);

  if (ipid!=0) printf("父进程编号是:%d\n",getpid());
  else printf("子进程编号是:%d\n",getpid());

  sleep(30);    // 是为了方便查看进程,在shell下用ps -ef|grep book252查看本进程的编号。
}

对fork函数进行系统调用时,fork函数创建了一个新的进程,新进程(子进程)与原有的进程(父进程)一模一样。子进程和父进程使用相同的代码段;子进程拷贝了父进程的堆栈段和数据段。子进程一旦开始运行,它复制了父进程的一切数据,然后各自运行,相互之间没有影响(父进程被kill后不会对子进程有任何影响)

3.2 多进程的应用

3.2.1 实现并发TCP服务器

如果把TCP服务端改为多进程,在每次accept到一个客户端的连接后,生成一个子进程,让子进程负责和这个客户端通信,父进程继续accept客户端的连接,socket的服务端在监听新客户端的同时,还可以与多个客户端进行通信。这就是并发(这个方法只有在Linux下能实现)

3.2.2 僵尸进程

僵尸进程是当子进程比父进程先结束,而父进程又没有回收子进程,释放子进程占用的资源,此时子进程将成为一个僵尸进程。如果父进程先退出 ,子进程被init接管,子进程退出后init会自动回收其占用的相关资源
僵尸进程在消失之前会持续占用系统资源(如果已经产生了僵尸进程则只有当父进程终止后僵尸进程才会随之消失)
解决僵尸进程的方法有两种:

  1. 子进程退出之前,会向父进程发送一个信号,父进程调用wait函数等待这个信号,只要等到了,就不会产生僵尸进程。这话说得容易,在并发的服务程序中这是不可能的,因为父进程要做其它的事,例如等待客户端的新连接,不可能去等待子进程的退出信号
  2. 另一种方法就是父进程直接忽略子进程的退出信号(意思是直接把子进程交给init?)

3.3 总结

从效率方面来说,某些场景下多进程的效率比单进程低,原因很简单,因为在有限的硬件资源中,多进程程序的内存开销更大,还会产生资源的竞争。就像多个人端着一盆水,不如一个人端着一盆水走得快

4.进程通信

进程的数据空间是独立的,私有的,不能相互访问,但是在某些情况下进程之间需要通信来实现某功能交换数据,包括:
1)数据传输:一个进程需要将它的数据发送给另一个进程。
2)共享数据:多个进程想要操作共享数据,一个进程对共享数据的修改,别的进程应该立刻看到。
3)通知事件:一个进程需要向另一个或一组进程发送消息,通知它(它们)发生了某种事件(如通知进程退出)。
4)进程控制:一个进程希望控制另一个进程的运行。

4.1 进程通信的方式

进程通信的方式大概分为六种:
1)管道:包括无名管道(pipe)及命名管道(named pipe),无名管道可用于具有父进程和子进程之间的通信。命名管道克服了管道没有名字的限制,因此,除具有管道所具有的功能外,它还允许无亲缘关系进程间的通信。
2)消息队列(message):进程可以向队列中添加消息,其它的进程则可以读取队列中的消息。
3)信号(signal):信号用于通知其它进程有某种事件发生。
4)共享内存(shared memory):多个进程可以访问同一块内存空间。
5)信号量(semaphore):也叫信号灯,用于进程之间对共享资源进行加锁。
6)套接字(socket):可用于不同计算机之间的进程间通信。

应用经验:
1)管道和消息队列太过时了,实在没什么应用价值,了解概念就行。
2)socket可以用于不同系统之间的进程通信,完全可以代替只能在同一系统中进程之间通信的管道和消息队列
3)信号的应用场景非常多,主要用于进程的控制,例如通知正在运行中的后台服务程序退出(后台!=服务器)。
4)同一系统中,进程之间采用共享内存交换数据的效率是最高的,但是,共享内存没有加锁的机制,所以经常与信号灯结合一起来使用,在高性能的网络服务端程序中,可以用共享内存作为的数据缓存(cache)。
5)在企业IT系统内部,消息队列已经逐渐成为通信的核心手段,它具有低耦合、可靠投递、广播、流量控制、一致性等一系列功能。当今市面上有很多主流的消息中间件有Redis、RabbitMQ、Kafka、ActiveMQ、ZeroMQ,阿里巴巴自主开发RocketMQ等。

4.2 Linux后台

Q:如何让程序在后台运行?
A:有两种方式实现程序的后台运行

4.2.1 后台的概念

在Linux的终端下,如果要运行程序,在命令提示行下输入程序名后回车,程序被执行,然后等待程序运行完成,在程序运行的过程中,也可以用Ctrl+c中止它。
手机的“应用程序在后台运行”意思是手机关掉软件的主界面后,软件的应用程序依然在后台运行和工作。 手机应用程序在你没有主动结束掉时,会一直处于运行状态,并且消耗你的手机电量
后台是相对桌面而言的,桌面如果是前台,执行时我们就能感知。而后台,是使用者看不到的

4.2.2 程序在后台运行

(1)加“&”符号
如果想让程序在后台运行,执行程序的时候,命令的最后面加“&”符号
./book250 &
在后台运行的程序,用Ctrl+c无法中断,并且就算终端退出了,程序仍在后台运行(如果终端退出了,后台运行的程序将由系统托管)
(2)使用fork
另一种方法是采用fork,主程序执行fork,生成一个子进程,然后父进程退出,留下子进程继续运行,子进程将由系统托管

4.2.3 如何终止后台程序

在Win环境下我们直接打开任务管理器右键关闭想要终止的进程即可,但是在Linux环境下我们需要用命令行操作(离开了终端控制,用Ctrl+c上无法中止该后台进程)
(1)killall 程序名
(2)kill 进程编号

4.3 Linux信号

信号是进程之间相互传递消息的一种方法,信号全称为软中断信号,也有人称作软中断,从它的命名可以看出,它的实质和使用很像中断(中断(interrupt)是指在CPU正常运行期间,由于内、外部事件引起的CPU暂时停止正在运行的程序,去执行该内部事件或外部事件引起的服务中去,服务完毕后再返回断点处继续执行的情形

4.3.1 信号的基本概念

软中断信号(signal,又简称为信号)用来通知进程发生了事件。进程之间可以通过调用kill库函数发送软中断信号。Linux内核也可能给进程发送信号,通知进程发生了某个事件(例如内存越界)
注意,信号只是用来通知某进程发生了什么事件,无法给进程传递任何数据,进程对信号的处理方法有三种
1)第一种方法是,忽略某个信号,对该信号不做任何处理,就象未发生过一样。
2)第二种是设置中断的处理函数,收到信号后,由该函数来处理。
3)第三种方法是,对该信号的处理采用系统的默认操作,大部分的信号的默认操作是终止进程。

4.3.2 信号的类型

发出信号的原因很多,这里按发出信号的原因介绍各种信号

信号名 信号值 默认处
理动作
发出信号的原因
SIGHUP 1 A 终端挂起或者控制进程终止
SIGINT 2 A 键盘中断Ctrl+c(强制中断前台进程)
SIGQUIT 3 C 键盘的退出键被按下
SIGILL 4 C 非法指令
SIGABRT 6 C 由abort(3)发出的退出指令
SIGFPE 8 C 浮点异常
SIGKILL 9 AEF 采用kill -9 进程编号 强制杀死程序。
SIGSEGV 11 C 无效的内存引用
SIGPIPE 13 A 管道破裂:写一个没有读端口的管道
SIGALRM 14 A 由alarm(2)发出的信号
SIGTERM 15 A 采用“kill 进程编号”或“killall 程序名”通知程序。
SIGUSR1 30,10,16 A 用户自定义信号1
SIGUSR2 31,12,17 A 用户自定义信号2
SIGCHLD 20,17,18 B 子进程结束信号
SIGCONT 19,18,25 **
进程继续(曾被停止的进程)
SIGSTOP 17,19,23 DEF 终止进程
SIGTSTP 18,20,24 D 控制终端(tty)上按下停止键
SIGTTIN 21,21,26 D 后台进程企图从控制终端读
SIGTTOU 22,22,27 D 后台进程企图从控制终端写

默认处理动作中的字母含义如下

字母 缺省动作
A 终止进程
B 忽略此信号,将该信号丢弃,不做处理
C 终止进程并进行内核映像转储
D 停止进程,进入停止状况以后还能重新进行下去
E 信号不能被捕获(意味着无法使用signal函数)
F 信号不能被忽略

4.3.3 信号的作用

服务程序运行在后台,如果想让中止它,强行杀掉不是个好办法,因为程序被杀的时候,程序突然死亡,没有释放资源,会影响系统的稳定,用Ctrl+c中止与强制杀程序是相同的效果
如果能向后台程序发送一个信号(我们需要对这个信号进行绑定对应的处理函数),后台程序收到这个信号后,调用对应的处理函数,在函数中执行释放资源的代码,程序就可以有计划的退出,安全而体面
信号还可以用于网络服务程序抓包等,这是较复杂的应用场景,暂时不介绍。

4.3.4 处理信号

signal库函数可以设置程序对信号的处理方式(即上面说到的进程对信号的三种处理方式)

sighandler_t signal(int signum, sighandler_t handler);
/*
@signum:参数signum表示信号的编号(信号值不是信号编号,信号值就是信号名,而信号编号是信号唯一的标识)
@handler:参数handler表示信号的处理方式,有下列三种情况
1)SIG_IGN:忽略参数signum所指的信号。
2)一个自定义的处理信号的函数,信号的编号为这个自定义函数的参数。(所以我是不是可以把强制中断信号修改为毫不相关的作用...)
3)SIG_DFL:恢复参数signum所指信号的处理方法为默认动作

*/

一般程序员关注的信号有三个:SIGINT、SIGTERM和SIGKILL

  • 程序在运行的进程中,如果按Ctrl+c,将向程序发出SIGINT信号,信号编号是2。
  • 采用“kill 进程编号”或“killall 程序名”向程序发出的是SIGTERM信号,编号是15。
  • 采用“kill -9 进程编号”向程序发出的是SIGKILL信号,编号是9,此信号不能被忽略,也无法捕获(意味着无法对它使用signal库函数),程序将突然死亡。

程序员一般只用设置SIGINT和SIGTERM两个信号的处理函数就可以了,这两个信号可以使用同一个处理函数,函数的代码的作用就是释放资源

4.4 Linux共享内存

共享内存(Shared Memory)就是允许多个进程访问同一个内存空间,是在多个进程之间共享和传递数据最高效的方式。操作系统将不同进程之间共享内存安排为同一段物理内存,进程可以将共享内存连接到它们自己的地址空间中,如果某个进程修改了共享内存中的数据,其它的进程读到的数据也将会改变。
共享内存并未提供锁机制,也就是说,在某一个进程对共享内存的进行读写的时候,不会阻止其它的进程对它的读写。如果要对共享内存的读/写加锁,可以使用信号灯。

Linux中提供了一组函数用于操作共享内存,程序中需要包含以下头文件:

#include <sys/ipc.h>
#include <sys/shm.h>

(1)创建内存
int shmget(key_t key, size_t size, int shmflg);
//shmget函数用来获取或创建共享内存
/*
@key:十六进制整数,共享内存在系统中的编号,不同共享内存的编号不能相同,这一点由程序员保证(自定义)
@size:待创建的共享内存的大小,以字节为单位
@shmflg:共享内存的访问权限,与文件的权限一样,0666|IPC_CREAT表示全部用户对它可读写
*/

(2)连接内存
void *shmat(int shm_id, const void *shm_addr, int shmflg);
//把共享内存连接到当前进程的地址空间
/*
参数shm_id是由shmget函数返回的共享内存标识。
参数shm_addr指定共享内存连接到当前进程中的地址位置,通常为空,表示让系统来选择共享内存的地址。
参数shm_flg是一组标志位,通常为0。
调用成功时返回一个指向共享内存第一个字节的指针,如果调用失败返回-1
*/

(3)分离内存
int shmdt(const void *shmaddr);
//该函数用于将共享内存从当前进程中分离,相当于shmat函数的反操作
/*
参数shmaddr是shmat函数返回的地址。
调用成功时返回0,失败时返回-1.
*/

(4)删除内存
int shmctl(int shm_id, int command, struct shmid_ds *buf);
//删除共享内存(shmctl是控制共享内存的函数,其功能不只是删除共享内容,但其它的功能没什么用)
/*
参数shm_id是shmget函数返回的共享内存标识符。
参数command填IPC_RMID。
参数buf填0。
*/
//注意:用root创建的共享内存,不管创建的权限是什么,普通用户无法删除
int main()
{
  int shmid; // 共享内存标识符,用于接收创建的共享内存

  // 1.创建共享内存,键值为0x5005,共1024字节。
  if ( (shmid = shmget((key_t)0x5005, 1024, 0640|IPC_CREAT)) == -1)
  { printf("shmat(0x5005) failed\n"); return -1; }

  char *ptext=0;   // 用于指向共享内存的指针

  // 2.将共享内存连接到当前进程的地址空间,由ptext指针指向它
  ptext = (char *)shmat(shmid, 0, 0);

  // 3.操作本程序的ptext指针,也就是操作共享内存
  printf("写入前:%s\n",ptext);
  sprintf(ptext,"本程序的进程号是:%d",getpid());
  printf("写入后:%s\n",ptext);

  // 4.把共享内存从当前进程中分离
  shmdt(ptext);

  // 5.删除共享内存
  // if (shmctl(shmid, IPC_RMID, 0) == -1)
  // { printf("shmctl(0x5005) failed\n"); return -1; }
}

(5)查看内存

ipcs -m可以查看系统的共享内存

4.5 Linux信号灯

信号量(信号灯)本质上是一个计数器,用于协调多个进程(包括但不限于父子进程)对共享数据对象的读/写。它不以传送数据为目的,主要是用来保护共享资源(共享内存、消息队列、socket连接池、数据库连接池等),保证共享资源在一个时刻只有一个进程独享
信号量是一个特殊的变量,只允许进程对它进行等待信号和发送信号操作。最简单的信号量是取值0和1的二元信号量,这是信号量最常见的形式。通用信号量(可以取多个正整数值)和信号量集方面的知识比较复杂,应用场景也比较少。
Linux中提供了一组函数用于操作信号量,程序中需要包含以下头文件

#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/sem.h>

(1)获取或创建信号量
int semget(key_t key, int nsems, int semflg);
/*
@key:是信号量的键值(具体要求和共享内存的键值相同,因为都是key_t数据类型)
@nsems:是创建 信号量集 中信号量的个数,该参数只在创建 信号量集 时有效,这里固定填1。
@sem_flags:是一组标志,如果希望信号量不存在时创建一个新的信号量,可以和值IPC_CREAT做 按位或 操作。
如果没有设置IPC_CREAT标志并且信号量不存在,就会返错误(errno的值为2,No such file or directory)。

返回值:如果semget函数成功,返回信号量集的标识;失败返回-1,错误原因存于error中。
*/

(2)控制信号量
//该函数用来控制信号量(常用于设置信号量的初始值和销毁信号量)
int semctl(int semid, int sem_num, int command, 第四个参数);

/*
@semid:是由semget函数返回的信号量标识。
@sem_num:是 信号量集 数组的下标,表示某一个信号量,填0。
@cmd:是对信号量操作的命令种类,常用的有以下两个:
    IPC_RMID:销毁信号量,不需要第四个参数;
    SETVAL:初始化信号量的值(信号量成功创建后,需要设置初始值),这个值由第四个参数决定。
@第四参数是一个自定义的共同体

返回值:如果semctl函数调用失败返回-1;如果成功,返回值比较复杂,暂时不关心它
*/

(3)等待锁&释放锁
/*
该函数有两个功能:
1)等待信号量的值变为1,如果等待成功,立即把信号量的值置为0,这个过程也称之为等待锁;
2)把信号量的值置为1,这个过程也称之为释放锁。
/*
int semop(int semid, struct sembuf *sops, unsigned nsops);
/*
@semid是由semget函数返回的信号量标识。
@nsops是操作信号量的个数,即sops结构变量的个数,设置它的为1(只对一个信号量的操作)。
@sops是一个结构体
*/

三、Linux多线程

1.线程的概念

和多进程相比,多线程是一种比较节省资源的多任务操作方式。启动一个新的进程必须分配给它独立的地址空间,每个进程都有自己的堆栈段和数据段,系统开销比较高,进行数据的传递只能通过进行间通信的方式进行。在同一个进程中,可以运行多个线程运行于同一个进程中的多个线程,它们彼此之间使用相同的地址空间,共享全局变量和对象,启动一个线程所消耗的资源比启动一个进程所消耗的资源要少

2.线程的使用

2.1 创建线程

int pthread_create(pthread_t *thread, const pthread_attr_t *attr,void *(*start_routine) (void *), void *arg);
/*
@thread:为指向线程标识符的地址。

@attr:用于设置线程属性,一般为空,表示使用默认属性。

@start_routine:是线程运行函数的地址,填函数名就可以了。

@arg:是线程运行函数的参数。新创建的线程从start_routine函数的地址开始运行,
该函数只有一个无类型指针参数arg。若要想向start_routine传递多个参数,可以将多个参数放在一个结构体中,
然后把结构体的地址作为arg参数传入,但是要非常慎重,程序员一般不会这么做。
*/

注意:在编译时注意加上-lpthread参数,以调用静态链接库。因为pthread并非Linux系统的默认库。(在Windows系统下也需要手动添加)

2.2 终止线程

如果进程中的任一线程调用了exit,则整个进程会终止,所以,在线程的start_routine函数(线程运行函数)中,不能采用exit。
线程的终止有三种方式:
1)线程的start_routine函数代码结束,自然消亡。
2)线程的start_routine函数调用pthread_exit函数结束。
3)被主进程或其它线程中止。

void pthread_exit(void *retval);
//参数retval填空,即0

2.3 多线程实现TCP并发服务器

创建多个子线程,每个子线程负责自己的一部分工作不会导致冲突(下面的代码不仅仅只是涉及多线程,同时也总结了怎么使用上面说到的进程之间的通信方式)

#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <netdb.h>
#include <stdlib.h>
#include <signal.h>
#include <pthread.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>

class CTcpServer
{
public:
  int m_listenfd;   // 服务端用于监听的socket
  int m_clientfd;   // 客户端连上来的socket

  CTcpServer();

  bool InitServer(int port);  // 初始化服务端

  bool Accept();  // 等待客户端的连接

  // 向对端发送报文
  int  Send(const void *buf,const int buflen);
  // 接收对端的报文
  int  Recv(void *buf,const int buflen);

  // void CloseClient();    // 关闭客户端的socket,多线程服务端不需要这个函数。
  // void CloseListen();    // 关闭用于监听的socket,多线程服务端不需要这个函数。

 ~CTcpServer();
};

CTcpServer TcpServer;

// SIGINT和SIGTERM的处理函数
void EXIT(int sig)
{
  printf("程序退出,信号值=%d\n",sig);

  close(TcpServer.m_listenfd);  // 手动关闭m_listenfd,释放资源

  exit(0);
}

// 与客户端通信线程的主函数
void *pth_main(void *arg);

int main()
{
  // 忽略全部的信号
  for (int ii=0;ii<50;ii++) signal(ii,SIG_IGN);

  // 设置SIGINT和SIGTERM的处理函数
  signal(SIGINT,EXIT); signal(SIGTERM,EXIT);

  if (TcpServer.InitServer(5051)==false)
  { printf("服务端初始化失败,程序退出。\n"); return -1; }

  while (1)
  {
    if (TcpServer.Accept() == false) continue;

    pthread_t pthid;   // 创建一线程,与新连接上来的客户端通信
    if (pthread_create(&pthid,NULL,pth_main,(void*)((long)TcpServer.m_clientfd))!=0)
    { printf("创建线程失败,程序退出。n"); return -1; }

    printf("与客户端通信的线程已创建。\n");
  }
}

CTcpServer::CTcpServer()
{
  // 构造函数初始化socket
  m_listenfd=m_clientfd=0;
}

CTcpServer::~CTcpServer()
{
  if (m_listenfd!=0) close(m_listenfd);  // 析构函数关闭socket
  if (m_clientfd!=0) close(m_clientfd);  // 析构函数关闭socket
}

// 初始化服务端的socket,port为通信端口
bool CTcpServer::InitServer(int port)
{
  if (m_listenfd!=0) { close(m_listenfd); m_listenfd=0; }

  m_listenfd = socket(AF_INET,SOCK_STREAM,0);  // 创建服务端的socket

  // 把服务端用于通信的地址和端口绑定到socket上
  struct sockaddr_in servaddr;    // 服务端地址信息的数据结构
  memset(&servaddr,0,sizeof(servaddr));
  servaddr.sin_family = AF_INET;  // 协议族,在socket编程中只能是AF_INET
  servaddr.sin_addr.s_addr = htonl(INADDR_ANY);  // 本主机的任意ip地址
  servaddr.sin_port = htons(port);  // 绑定通信端口
  if (bind(m_listenfd,(struct sockaddr *)&servaddr,sizeof(servaddr)) != 0 )
  { close(m_listenfd); m_listenfd=0; return false; }

  // 把socket设置为监听模式
  if (listen(m_listenfd,5) != 0 ) { close(m_listenfd); m_listenfd=0; return false; }

  return true;
}

bool CTcpServer::Accept()
{
  if ( (m_clientfd=accept(m_listenfd,0,0)) <= 0) return false;

  return true;
}

int CTcpServer::Send(const void *buf,const int buflen)
{
  return send(m_clientfd,buf,buflen,0);
}

int CTcpServer::Recv(void *buf,const int buflen)
{
  return recv(m_clientfd,buf,buflen,0);
}

// 与客户端通信线程的主函数
void *pth_main(void *arg)
{
  int clientfd=(long) arg; // arg参数为新客户端的socket。

  // 与客户端通信,接收客户端发过来的报文后,回复ok。
  char strbuffer[1024];

  while (1)
  {
    memset(strbuffer,0,sizeof(strbuffer));
    if (recv(clientfd,strbuffer,sizeof(strbuffer),0)<=0) break;
    printf("接收:%s\n",strbuffer);

    strcpy(strbuffer,"ok");
    if (send(clientfd,strbuffer,strlen(strbuffer),0)<=0) break;
    printf("发送:%s\n",strbuffer);
  }

  printf("客户端已断开连接。\n");

  close(clientfd);  // 关闭客户端的连接。

  pthread_exit(0);
}

注意:
1)线程主函数的函数体中,不能使用return;语句(return会直接退出函数体回到调用处,如果该函数体是main函数则直接退出程序),如果想退出线程,可以用pthread_exit(0);返回。
2)线程可以共享全局变量,当然也可以共享TcpServer的m_clientfd成员变量。但是,创建线程的时候,为什么要把客户端的socket用参数传给线程主函数,而不是直接获取TcpServer.m_clientfd的值,因为主进程调用pthread_create创建线程后,立即返回循环重新Accept,创建线程需要时间,如果在这段时间内有新的客户端连接上来,TcpServer.m_clientfd的值会发生改变。

3.线程资源

线程有joinable和unjoinable两种状态,如果线程是joinable状态,当线程主函数终止时(自己退出或调用pthread_exit退出)不会释放线程所占用内存资源和其它资源,这种线程被称为“僵尸线程”。创建线程时默认是非分离的(disdetach),或者称为可连接的(joinable)。避免僵尸线程就是如何正确的回收线程资源,有四种方法:
1)方法一:创建线程后,在创建线程的程序中调用pthread_join等待线程退出,一般不会采用这种方法,因为pthread_join会发生阻塞。
2)方法二:创建线程前,调用pthread_attr_setdetachstate将线程设为detached,这样线程退出时,系统自动回收线程资源。
3)方法三:创建线程后,在创建线程的程序中调用pthread_detach将新创建的线程设置为detached状态。
4)方法四:在线程主函数中调用pthread_detach改变自己的状态。

4.线程锁

多线程可以共享资源(变量和对象),对编程带来了方便,但是某些对象虽然可以共享,但在同一个时间只能由一个线程使用,多个线程同时使用会产生冲突,例如socket连接,数据库连接池。
多线程来说,资源是共享的,基本上不存在不允许访问的情况,但是,共享的资源在某一时间点只能有一个线程占用,所以需要给资源加锁(类似于多进程中的资源共享使用的信号灯)。线程锁也被称为线程同步,并不是锁线程,而是锁共享资源,即线程给共享资源加的锁
常见的锁有如下两类:
1)不允许访问的锁容易理解,就像每家每户的门锁,不允许外人进入。
2)第二种锁,例如火车上的厕所,它是公共的,空闲的时候任何人可以进入,人进去以后就会把它锁起来,其它的人如果要上厕所,必须等待解锁,即里面的人出来。还有红绿灯,红灯是加锁,绿灯是解锁。(互斥锁、信号灯)
线程的锁的种类有互斥锁、读写锁、条件变量、自旋锁、信号灯。

4.1 互斥锁

互斥锁机制是同一时刻只允许一个线程占有共享的资源。

(1)初始化锁
int pthread_mutex_init(pthread_mutex_t *mutex,const pthread_mutex_attr_t *mutexattr);
/*
@mutexattr:用于指定锁的属性(见下),如果为NULL则使用缺省属性
*/

互斥锁的属性在创建锁的时候指定,当资源被某线程锁住的时候,其它的线程在试图加锁时表现将不同。当前有四个值可供选择:
1)PTHREAD_MUTEX_TIMED_NP,这是缺省值,也就是普通锁。当一个线程加锁以后,其余请求锁的线程将形成一个等待队列,并在解锁后按优先级获得锁(这与下面的阻塞/非阻塞加锁是否有关系?)。这种锁策略保证了资源分配的公平性。
2)PTHREAD_MUTEX_RECURSIVE_NP嵌套锁,允许同一个线程对同一个锁成功获得多次(多重加锁),并通过多次unlock解锁。
3)PTHREAD_MUTEX_ERRORCHECK_NP检错锁,如果同一个线程请求同一个锁(不允许多重锁),则返回EDEADLK,否则与PTHREAD_MUTEX_TIMED_NP类型动作相同。
4)PTHREAD_MUTEX_ADAPTIVE_NP适应锁,动作最简单的锁类型,等待解锁后重新竞争(也就是不允许排队?)。

(2)阻塞加锁
int pthread_mutex_lock(pthread_mutex *mutex);
//如果是锁是空闲状态,本线程将获得这个锁;如果锁已经被占据,本线程将排队等待,直到成功的获取锁。

(3)非阻塞加锁
int pthread_mutex_trylock( pthread_mutex_t *mutex);
//该函数语义与 pthread_mutex_lock() 类似,不同的是在锁已经被占据时立即返回 EBUSY,不是挂起等待。

(4)解锁
int pthread_mutex_unlock(pthread_mutex *mutex);
//线程把自己持有的锁释放。

(5)销毁锁
int pthread_mutex_destroy(pthread_mutex *mutex);
//销毁锁之前,锁必需是空闲状态(unlock),否则返回EBUSY

5.总结

Linux没有真正意义上的线程,它的实现是由进程来模拟,属于用户级线程。所以,在Linux系统下,进程与线程在性能和资源消耗方面没有本质的差别。
对我们程序员来说,进程不能共享全局数据,线程可以共享全局数据,各位可以根据应用场景选择采用多进程或多线程