0%

前言

统一建模语言(Unified Modeling Language,UML)是用来设计软件蓝图的可视化建模语言,1997年被国际对象管理组织(OMG)采纳为面向对象的建模语言的国际标准。它的特点是简单、统一、图形化、能表达软件设计中的动态与静态信息。
统一建模语言能为软件开发的所有阶段提供模型化和可视化支持。而且融入了软件工程领域的新思想、新方法和新技术,使软件设计人员沟通更简明,进一步缩短了设计时间,减少开发成本。它的应用领域很宽,不仅适合于一般系统的开发,而且适合于并行与分布式系统的建模。
UML从目标系统的不同角度出发,定义了用例图、类图、对象图、状态图、活动图、时序图、协作图、构件图、部署图等 9 种图
本文介绍开发中常用的类图

类图

类(Class)是指具有相同属性、方法和关系的对象的抽象,它封装了数据和行为,是面向对象程序设计(OOP)的基础,具有封装性、继承性和多态性等三大特性。在 UML 中,类使用包含类名、属性和操作且带有分隔线的矩形来表示。
首先讲解关系, 先来看一个例子:
image-20221205114810203

分析一下上面的图, 首先从动物开始
动物是一个类 动物依赖氧气和水
然后鸟继承了动物,所以鸟的父类是动物 所以鸟是属于动物
然后鸟和翅膀是组合关系 一只鸟有两个翅膀
大雁鸭子和企鹅都是鸟所以继承了鸟类
大雁会有大雁群,大雁群是由大雁组成所以是聚合关系
企鹅和气候是关联关系因为企鹅需要依赖气候
然后再看大雁 大雁会飞翔 所以就实现了飞翔接口
唐老鸭是属于鸭子的 所以唐老鸭继承了鸭子这个类
上图是借鉴了大话设计模式里面的图。下面具体介绍各个符号的作用

类一般是用三层矩形框表示,第一层表示类的名称,第二层表示的是字段和属性,第三层则是类的方法。第一层中,如果是抽象类,需用斜体显示
image-20221205114819689

类符号

image-20221205114830981
看上面的学生类里面有五个属性和两个方法

+号表示公共的 public
-表示 私有的 private
#表示protected

带下划线表示静态属性,一般表示方法: +属性:类型。
括号内表示参数,后面是返回类型, 没有表示无返回值

包(Package): 是一种常规用途的组合机制。在UML中用一个Tab框表示,Tab里写上包的名称,框里则用来放一些其他子元素,比如类,子包等等。
image-20221205114837947

接口

接口(interface):接口包含操作但不包含属性,且它没有对外界可见的关联
image-20221205114843929

关系

依赖

依赖(Dependency) 表示的是类之间的调用关系。UML中用带箭头的虚线表示依赖关系,而箭头所指的则是被依赖的类。
image-20221205114849672

泛化

泛化(Generalization): 表示的是类之间的继承关系,注意是子类指向父类。UML中用带空心三角箭头的实线表示泛化关系,箭头指向的是一般个体。
image-20221205114855112

关联

关联(Association) 表示的是类与类之间存在某种特定的对应关系。UML中用双向带箭头的虚线表示关联关系,箭头两端为相互关联的两个类
image-20221205114902153

聚合

聚合(Aggregation): 是关联关系的一种特例,表示的是整体与部分之间的关系,部分不能离开整体单独存在。UML中用空心菱形头的实线表示聚合关系,菱形头指向整体
image-20221205114909206

组合

组合(Composition): 是聚合的一种特殊形式,表示的是类之间更强的组合关系。UML中用实心菱形头的实线来表示组合,菱形头指向整体。
image-20221205114949528

概述

I/O多路复用:通过一种机制,可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。

但select,poll,epoll本质上都是同步I/O,因为他们都需要在读写事件就绪后自己负责进行读写,也就是说这个读写过程是阻塞的,而异步I/O则无需自己负责进行读写,异步I/O的实现会负责把数据从内核拷贝到用户空间。

IO多路复用适用如下场合:

  • 当客户处理多个描述符时(一般是交互式输入和网络套接口),必须使用I/O复用。
  • 当一个客户同时处理多个套接口时,而这种情况是可能的,但很少出现。
  • 如果一个TCP服务器既要处理监听套接口,又要处理已连接套接口,一般也要用到I/O复用。
  • 如果一个服务器即要处理TCP,又要处理UDP,一般要使用I/O复用。
  • 如果一个服务器要处理多个服务或多个协议,一般要使用I/O复用。

与多进程和多线程技术相比,I/O多路复用技术的最大优势是系统开销小,系统不必创建进程/线程,也不必维护这些进程/线程,从而大大减小了系统的开销。

select实现

逻辑时序:
1
具体实现:
2

fd_set(监听的端口个数):32位机默认是1024个,64位机默认是2048。

(1)使用copy_from_user从用户空间拷贝fd_set到内核空间
(2)注册回调函数__pollwait
(3)遍历所有fd,调用其对应的poll方法(对于socket,这个poll方法是sock_poll,sock_poll根据情况会调用到tcp_poll,udp_poll或者datagram_poll)
(4)以tcp_poll为例,其核心实现就是__pollwait,也就是上面注册的回调函数。
(5)__pollwait的主要工作就是把current(当前进程)挂到设备的等待队列中,不同的设备有不同的等待队列,对于tcp_poll来说,其等待队列是sk->sk_sleep(注意把进程挂到等待队列中并不代表进程已经睡眠了)。在设备收到一条消息(网络设备)或填写完文件数据(磁盘设备)后,会唤醒设备等待队列上睡眠的进程,这时current便被唤醒了。
(6)poll方法返回时会返回一个描述读写操作是否就绪的mask掩码,根据这个mask掩码给fd_set赋值。
(7)如果遍历完所有的fd,还没有返回一个可读写的mask掩码,则会调用schedule_timeout是调用select的进程(也就是current)进入睡眠。当设备驱动发生自身资源可读写后,会唤醒其等待队列上睡眠的进程。如果超过一定的超时时间(schedule_timeout指定),还是没人唤醒,则调用select的进程会重新被唤醒获得CPU,进而重新遍历fd,判断有没有就绪的fd。
(8)把fd_set从内核空间拷贝到用户空间。

select的几大缺点:
(1)每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大
(2)同时每次调用select都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大
(3)select支持的文件描述符数量太小了,默认是1024

poll实现

poll的实现和select非常相似,只是描述fd集合的方式不同,poll使用pollfd结构而不是select的fd_set结构,而使其没有连接数的限制。其他的都差不多。

epoll

epoll支持水平触发和边缘触发,最大的特点在于边缘触发,它只告诉进程哪些fd刚刚变为就绪态,并且只会通知一次。还有一个特点是,epoll使用“事件”的就绪通知方式,通过epoll_ctl注册fd,一旦该fd就绪,内核就会采用类似callback的回调机制来激活该fd,epoll_wait便可以收到通知。
3

epoll的几大改进

epoll既然是对select和poll的改进,就应该能避免上述的三个缺点。那epoll都是怎么解决的呢?在此之前,我们先看一下epoll和select和poll的调用接口上的不同,select和poll都只提供了一个函数——select或者poll函数。而epoll提供了三个函数,epoll_create,epoll_ctl和epoll_wait,epoll_create是创建一个epoll句柄;epoll_ctl是注册要监听的事件类型;epoll_wait则是等待事件的产生。

对于第一个缺点,epoll的解决方案在epoll_ctl函数中。
每次注册新的事件到epoll句柄中时(在epoll_ctl中指定EPOLL_CTL_ADD),会把所有的fd拷贝进内核,而不是在epoll_wait的时候重复拷贝。epoll保证了每个fd在整个过程中只会拷贝一次。

对于第二个缺点,epoll的解决方案不像select或poll一样每次都把current轮流加入fd对应的设备等待队列中。
而只在epoll_ctl时把current挂一遍(这一遍必不可少)并为每个fd指定一个回调函数,当设备就绪,唤醒等待队列上的等待者时,就会调用这个回调函数,而这个回调函数会把就绪的fd加入一个就绪链表)。epoll_wait的工作实际上就是在这个就绪链表中查看有没有就绪的fd(利用schedule_timeout()实现睡一会,判断一会的效果,和select实现中的第7步是类似的)。即epoll最大的优点就在于它只管你“活跃”的连接,而跟连接总数无关。

对于第三个缺点,epoll没有这个限制。
它所支持的FD上限是最大可以打开文件的数目,这个数字一般远大于2048,举个例子,在1GB内存的机器上大约是10万左右,具体数目可以cat /proc/sys/fs/file-max察看,一般来说这个数目和系统内存关系很大。

epoll小结:

(1)select,poll实现需要自己不断轮询所有fd集合,直到设备就绪,期间可能要睡眠和唤醒多次交替。而epoll其实也需要调用epoll_wait不断轮询就绪链表,期间也可能多次睡眠和唤醒交替,但是它是设备就绪时,调用回调函数,把就绪fd放入就绪链表中,并唤醒在epoll_wait中进入睡眠的进程。虽然都要睡眠和交替,但是select和poll在“醒着”的时候要遍历整个fd集合,而epoll在“醒着”的时候只要判断一下就绪链表是否为空就行了,这节省了大量的CPU时间。这就是回调机制带来的性能提升。
(2)select,poll每次调用都要把fd集合从用户态往内核态拷贝一次,并且要把current往设备等待队列中挂一次,而epoll只要一次拷贝,而且把current往等待队列上挂也只挂一次(在epoll_wait的开始,注意这里的等待队列并不是设备等待队列,只是一个epoll内部定义的等待队列)。这也能节省不少的开销。

select、poll、epoll区别

  1. 支持一个进程所能打开的最大连接数
    4

  2. FD剧增后带来的IO效率问题
    5

  3. 消息传递方式

总结

在选择select,poll,epoll时要根据具体的使用场合以及这三种方式的自身特点:

  • 连接数多,活跃链接占比不高的场景下,epoll的性能最好
  • 在连接数少并且连接都十分活跃的情况下,select和poll的性能可能比epoll好,毕竟epoll的通知机制需要很多函数回调。
  • select低效是因为每次它都需要轮询。但低效也是相对的,可通过良好的设计改善。

1.简介

Tinyhttpd是一个C + CGI实现的简单http server,适合初学者学习。代码许可协议:GPL,copyright 1999, by J. David Blackstone.
本文对Tinyhttp稍作注释和改动,验证并理解其主要流程, 本文源码:
Github: cursorhu/myTinyHttpd

2.背景知识

TCP套接字的通信流程

网络协议栈的核心是TCP/IP协议,HTTP本质上是对TCP的应用层封装,要理解HTTP服务程序,首先要理解TCP层的通信机制,在Linux环境中TCP采用socket接口通信,流程如下图
image-20221212145149039
关于Linux网络编程相关知识,参考《Linux网络编程-第二版》
TinyHttpd实现服务端的流程。

HTTP的请求方式

参考:
浅谈HTTP中GET、POST用法以及它们的区别
99%的人都理解错了HTTP中GET与POST的区别
理解以下几点:

  • GET,POST,PUT,DELETE是http层对数据操作的封装,底层本质还是TCP的read/write过程
  • http server处理请求的基本流程:读取-拆解-处理-封装-回写,拆解和封装的就是http层的请求和数据格式,处理是指TCP层能理解的数据。就像快递退货时的流程:取件-拆包-查看-装包-寄出

CGI的时代背景

参考:CGI是什么

  • CGI是2000年的web接口标准,后端部署perl-CGI脚本,连接server处理程序和web客户端
  • CGI目前还应用在嵌入式web等C-based环境,这个和当前web主流的Java Spring + Vue(JS)是完全不同的应用场景,所以CGI技术本身并无过时一说。

3.调试httpd

部署httpd服务

Aliyun CentOS环境,运行如下deploy.sh:

#!/bin/bash
chmod +x htdocs/*.cgi
yum install -y perl perl-CGI
make clean && make

image-20221212145206784

浏览器访问httpd

服务端直接运行httpd,会分配随机可用端口,本地chrome浏览器访问该服务所在的ip:端口
image-20221212145218796

这里ip即为httpd所在主机ip,默认访问资源是htdocs/index.html,原因可见httpd.c的http Get请求解析url的处理
image-20221212145227807

index.h调用color.cgi脚本:

<HTML>
<TITLE>Index</TITLE>
<BODY>
<P>Welcome to J. David's webserver.
<H1>CGI demo: get color
<FORM ACTION="color.cgi" METHOD="POST">
Enter color(example: red, pink, blue): <INPUT TYPE="text" NAME="color">
<INPUT TYPE="submit">
</FORM>
</BODY>
</HTML>

color.cgi内容:

#!/usr/bin/perl -Tw

use strict;
use CGI;

my($cgi) = new CGI;

print $cgi->header;
my($color) = "blue";
$color = $cgi->param('color') if defined $cgi->param('color');

print $cgi->start_html(-title => uc($color),
                       -BGCOLOR => $color); 
print $cgi->h1("This is $color");
print $cgi->end_html;

干了两件事:

  • html页面的bgcolor参数设置成了用户输入的color变量字符串
  • 显示字符串:This is $color

输入“red”, 浏览器显示效果:
image-20221212145241997

F12打开浏览器调试窗口,可见:

  • 访问资源为color.cgi
  • 查看http head内容,浏览器客户端的请求是POST,类型是text文本,表单数据(Form data):color的值是red
  • 查看http response内容,即httpd返回的内容。返回了html文本,即浏览器可见的红色页面

image-20221212145258548

image-20221212145533364

现在理解以下整个流程:

  • 服务器上httpd先运行,处于监听(listen)客户端请求的状态
  • 本地浏览器输入服务器ip:端口,访问httpd,发送的http请求类型是GET,即获取文本
  • httpd收到请求,在处理过程中调用cgi脚本,生成response的内容
  • httpd打包内容成http层的格式(head+body+…),返回浏览器客户端
  • 浏览器客户端解析html文本并显示成可见的页面。

再看另外一个获取时间的功能:
浏览器输入ip:port/date.html
image-20221212145316645
访问的资源是date.cgi,返回了显示当前时间的页面
image-20221212145338163
看下http请求和响应
image-20221212145345780

image-20221212145353230

date.cgi的实现:shell直接调用linux date命令

#!/bin/bash
echo "Content-Type: text/html"
echo
echo "<HTML><BODY>"
echo "<CENTER>Today is:</CENTER>"
echo "<CENTER><B>"
date
echo "</B></CENTER>"
echo "</BODY></HTML>"

TCP socket访问httpd(测试)

client.c直接使用socket接口访问httpd,这是个测试功能,因此用编译参数控制了该功能, make test_sock=y编译该版本的httpd
image-20221212145401934
client和httpd在同一主机,直接访问回环地址127.0.0.1,可见httpd返回了client发送的字符’A’

4.源码分析

(1) httpd的处理http请求的主要流程

image-20221212145417431

  1. 服务器启动,在指定端口或随机选取端口绑定 httpd 服务
  2. 收到一个 HTTP 请求时(其实就是 listen 的端口 accpet 的时候),派生一个线程运行 accept_request 函数
  3. 取出 HTTP 请求中的 method (GET 或 POST) 和 url,。对于 GET 方法,如果有携带参数,则 query_string 指针指向 url 中 ? 后面的 GET 参数
  4. 格式化 url 到 path 数组,表示浏览器请求的服务器文件路径,在 tinyhttpd 中服务器文件是在 htdocs 文件夹下。当 url 以 / 结尾,或 url 是个目录,则默认在 path 中加上 index.html,表示访问主页
  5. 如果文件路径合法,对于无参数的 GET 请求,直接输出服务器文件到浏览器,即用 HTTP 格式写到套接字上,然后跳到(10)。其他情况(带参数 GET,POST 方式,url 为可执行文件),则调用 excute_cgi 函数执行 cgi 脚本
  6. 读取整个 HTTP 请求并丢弃,如果是 POST 则找出 Content-Length. 把 HTTP 200 状态码写到套接字
  7. 建立两个管道,cgi_input 和 cgi_output, 并 fork 一个进程
  8. 在子进程中,把 STDOUT 重定向到 cgi_outputt 的写入端,把 STDIN 重定向到 cgi_input 的读取端,关闭 cgi_input 的写入端 和 cgi_output 的读取端,设置 request_method 的环境变量,GET 的话设置 query_string 的环境变量,POST 的话设置 content_length 的环境变量,这些环境变量都是为了给 cgi 脚本调用,接着用 execl 运行 cgi 程序
  9. 在父进程中,关闭 cgi_input 的读取端 和 cgi_output 的写入端,如果 POST 的话,把 POST 数据写入 cgi_input,已被重定向到 STDIN,读取 cgi_output 的管道输出到客户端,该管道输入是 STDOUT。接着关闭所有管道,等待子进程结束。管道状态参考下图。
  10. 关闭与浏览器的连接,完成了一次 HTTP 请求与回应, HTTP是无连接的。

管道初始状态:
image-20221212145427091
管道最终状态:
image-20221212145432542

主要函数:

  • startup: 初始化httpd服务,包括建立服务端的套接字,绑定端口,进行监听等
  • accept_request: 处理从套接字上监听到的一个 HTTP 请求,是服务器处理请求的主流程
  • execute_cgi: 运行cgi程序的处理,对应POST请求
  • sever_file: 调用cat把服务器文件返回给浏览器,对应GET请求

辅助功能函数:

  • get_line: 读取套接字的一行,把回车换行等情况都统一为换行符结束
  • unimplemented: 返回给浏览器表明收到的HTTP请求所用的method不支持,httpd只支持GET和POST
  • headers: 把HTTP响应的头部写到套接字
  • cat: 读取服务器上的指定文件写到socket套接字

(2)httpd处理client的socket请求

参考TCP套接字流程,注意一点,server端回写数据后,要close掉,client才能正常close。
编译选项的实现讲一下:
Makefile根据输入参数,定义宏, 如果编译输入带参make test_sock=y,则定义宏TEST_SOCK,等价于在源码#define TEST_SOCK

#用编译选项定义宏
ifeq ($(test_sock), y)
CFLAGS+= -D TEST_SOCK
endif

httpd.c对宏的处理:

#ifdef TEST_SOCK
void test_sock(int);
#else
#define test_sock(...)  do{}while(0)
#endif

这里如果没定义TEST_SOCK,直接把test_sock函数声明成do{}while(0)形式,这种控制在linux kernel源码中很常见,好处是不需要在调用处加宏控制,若TEST_SOCK未定义,调用test_sock()等价于空语句。...代表所有入参

扩展:httpd能否同时支持浏览器和client程序访问?
一个socket描述符只能对应一个客户端,如果server想要一对多的IO复用,需要select-poll机制,参考:
IO多路复用之select、poll、epoll
linux下socket编程实现一个服务器连接多个客户端

参考文章

Tinyhttpd精读解析
EZLippi/Tinyhttpd

基础操作

拉取和同步

git clone http://xxx.xxx.git //http方式, 从远程clone仓库
git pull //拉取远程分支
git branch //查看本地
git branch -a //查看远程和本地
git checkout xxxbranch //本地切到某分支
git checkout xxx/xxx //仅拉取部分目录或文件

推送到远程

git add -A //推送所有修改到本地仓库
git commit -m "change logs" //提交到本地仓库(记录修改信息)
git push //推送本地分支到远程的同名分支,需要先关联
git push origin <本地分支名> //推送本地分支到远程同名分支
git push origin <本地分支名>:<远程分支名> //推送本地分支到远程指定分支

加tag/删tag

git tag -a TAGNAME -m "TAG LOG" //加tag
git push origin TAGNAME //推送tag到远程
git tag -d TAGNAME //删除本地tag
git push origin :refs/tags/TAGNAME //删除远程tag

创建/删除/修改分支

创建分支并关联远程

git checkout -b BRANCH_NAME //本地创建分支
git push origin BRANCH_NAME //推送到远程
git push --set-upstream origin BRANCH_NAME //关联远程,便于以后分支pull/push

删除本地分支

git branch -d branch_name
git branch -D branch_name //强制删除

删除远程分支

git push origin -d branch_name

分支重命名(本地)

git branch -m OLD_NAME NEW_NAME

版本比较

可以用git diff --help直接查看git diff的Manual Page

git diff COMMIT_ID //比较本地和某commit_id的内容
git diff ID1 ID2 //比较两个提交的内容,比较新增时,旧版本在前,新版本在后
git diff <path of file> //比较本地某文件的内容
git diff --name-only ID1 ID2 //只显示有差异的文件名列表
git diff <commit>..<commit> [<path>…] //比较两个提交中指定文件名或者路径的差异

版本回退

git reset --hard HEAD^ //回退到上个版本
git reset --hard HEAD^^ //回退到上上个版本
git reset --hard COMMIT_ID //回退到指定提交
git push -f //强制提交,覆盖远程,使远程也回退
git push origin master -f //强制推送到远程的master分支

合并分支

两个分支A和B,要把分支B的所有提交合并到A分支上

git checkout <branch A> //切到待合并分支A
git merge <branch B> //拉取分支B,合并到当前分支A
git merge <branch B>  --squash //合并分支,将B的多个提交融合成一个再合并到A,而不是B的所有提交记录都照搬到A(这个更常用)
git merge --abort //终止合并

如果有merge conflict,手动修改冲突文件->保存文件->git add -A提交修改->git commit -m "xxx"提交该合并

如果本地仓库已经处于待merge状态,又想取消merge,同步成远程仓库状态,只需要reset本地仓库到当前commit-id

git reset --hard HEAD

也可以reset到指定commit-id:

1
git reflog && git reset --hard commit-id

合并提交

合并当前提交

如果当前修改还未提交, 想合并到最近的一次提交里,例如最近提交有个错误,可以用--amend修订提交

git add -A
git commit --amend
git push -f //amend后通常强制推送,因为没有新增commit

合并历史提交

有时同一个功能分多次提交,提交过于频繁,需要合并成一个提交。
如下有三次提交

$git log
commit_3: xxxxx
    message_3 ....
commit_2: xxxxx
    message_2 ....
commit_1: xxxxx
    message_1 ....

现在想把commit_3 和 commit_2合并成一个commit.

git rebase -i commit_1 //重定位到要合并的前一个提交

进入commit信息编辑模式:

pick commit_2 message_2...
pick commit_3 message_3...

将要合并的commit_3前的属性pick(选用)改为squash(压扁),wq保存,进入当前合并commit的信息提交界面,再次wq保存, 查看合并后提交记录如下:

$git log
commit_4: xxxxx
    message_3 ....
    message_2 ....
commit_1: xxxxx
    message_1 ....

两次提交已合成一次(新的)提交

多人提交的冲突解决办法

A和B同时开发某项目的同一个分支,A拉取最新版本1.0后,在本地新增功能,此时B也在1.0上修改并提交到了新版本1.1到远程仓库。A在B提交之后再提交,发现自己本地的修改已是旧版本,无法直接提交,如下图是A的add,commit,push三连的结果

1631249531971_115

image-20221205100655798

image-20221205100726224

手动解决conflict

git pull 拉取远程仓库最新版本,此时有两种情况

  • 代码有冲突,需手动修改冲突区域的代码块,二选一,然后重新add-commit-push三连提交
  • 无冲突,pull代码会自动合并,直接重新三连提交即可

以下是有冲突的情况
image-20221205100836563

找到冲突源码,冲突的符号定义如下:

  • <<<<< HEAD:当前本地的代码块
  • ======:分割冲突块
  • >>>>>>b699a7fc:远程最新hash版本号的代码块

image-20221205100855921

修改方法:先拷贝冲突关键语句,再删除所有冲突域符号,最后只保留如下代码
image-20221205101900769

修改完后,git add, git commit, git push,成功提交
image-20221205102021265

查看提交后版本:git log

修改某次提交的commit信息

有时需要修改commit信息便于区分哪个是解决冲突后的提交
解决方案:

  • 修改最新的commit,只需要amend修改commit信息后,再push
  • 修改历史的commit,需要先rebase修改属性为edit后,再commit –amend

下面讲修改历史commit
如下图,想修改9877的commit信息
image-20221205102348131

先rebase到之前的commit
image-20221205102432433
显示其后的版本属性如下
image-20221205102447476
修改9877的属性为edit(待编辑模式),将原始commit改成如下内容,:wq保存:

image-20221205102658821

然后commit --amend, rebase --continue
image-20221205102751155
再查看下git og修改成功
最后git push同步到远程仓库

从另一个分支拉取指定的几个commit内容

A和B都在git的master分支提交代码,一天发现master某个版本有问题,回退n各版本都找不到是谁提交引起的问题,由于master还要作稳定测试等其他用途,决定先回退master分支到较早的指定版本,而master最新版和稳定版之间提交的内容,分别由各自A和B“认领”,拉取master上自己提交的功能到自己的分支,debug好以后在合并回master。
需求:
如何在开发者分支上拉取master分支的指定几个commit的内容,注意不是某个commit以前的内容,是commit内的内容?

创建自己分支,回退master

首先切到master分支上,创建一个自己的分支thomas,自己分支是master的拷贝

git checkout master //当前在那个分支,决定创建分支的内容
git checkout -b thomas //做两件事:在本地创建thomas分支,内容和master一样;切到thomas分支
git push --set-upstream origin thomas //推送分支到远程,这步很容易漏掉
git branch //查看当前在哪个分支
git branch -a //查看所有分支

以上操作完成后,自己分支就创建好了,注意动作只影响到本地仓库的.git文件,要同步远程仓库还要push到远程
下面备份master, 再回退master

git checkout master
git checkout -b master_backup //先备份master,上面有自己分支要拉取的内容
git checkout master //切到master,准备回退
git reset --hard COMMIT_ID //回退到稳定版本commit_id
git push -f //由于是回退,提交比远程的还早,一般需要强制提交,这个操作也会把本地的.git修改一同提交到远程

这样就有三个分支:

master: 包含稳定版本的旧代码
master_backup: master的备份,包含稳定版和之后的A、B的一些提交
thomas: 开发者A的个人分支,现在和master稳定版完全一样

下面只需要从master_backup拉取自己相关的提交到thomas分支即可。

cherry-pick拉取指定commit

先把要拉取的commit id存起来:

git checkout master_backup
git log > ../master_backup.log

截取commit log片段如图
image-20221205103857374

切到thomas分支,拉取master_backup的commit

git checkout thomas
git cherry-pick 3d6b3be

这种方法只拉了一个commit, 更好的方式是按功能,一次拉多个commit,甚至一次把所有的commit都拉完。
cherry-pick支持多个pick一步到位
例如git log如下

commit4 id4
commit3 id3
commit2 id2
commit1 id1

离散拉取:只拉取id1和id4:

git cherry-pick id1 id4

!注意,提交顺序很重要,旧版本写在前新版本写在后
如果是区间拉取,即全部的id1,id2, id3,id4

git cherry-pick id1..id4 //加两个点即为区间拉取

为了验证是不是真的拉取了多个版本,可以git diff --name-only id1 id4看下拉取后的修改哪些文件,对比被拉取分支的修改,如果一致,说明确实拉取多个commit
对于上图的commit,建议按功能多次cherry-pick并commit+push,便于后续debug。

cherry-pick的冲突问题

cherry-pick也是合并,只要是合并代码,就可能有冲突
image-20221205103926181
合并单个commit,使用使用常规的冲突解决办法即可:

  • 到源码改冲突, <<<< ===== >>>>三个标记之间代码块二选一
  • git status查看哪些待提交
  • git add -A提交修改后的源码到本地.git

单个提交的冲突解决

由于是从其他分支的commit id合并到当前分支(HEAD),可以不加考虑的删掉<<<<HEAD====之间的内容,采用====commit_id之间的内容,随后删掉三个标记即可。
image-20221205103951579
有可能出现冲突代码块有重叠区的情况

<<<< HEAD
code 1
=====
<<<< commit_id 1
code 2
>>>> commit_id 2
code 3
=====
code 4
>>>> commit_id 3

只要确定一个原则:<<<<是冲突块的起始点,====是分界,>>>>是终止点,分两步删代码就可以了。

多个提交的冲突解决:

如果是cherry-pick多个commit,冲突的解决方法就不一样了。
其区别在于,多个commit_id的cherry-pick,一旦遇到冲突,就会停下pick,需要手动解决冲突后,用cherry-pick --continue继续接下来的commit合并,直到由遇到冲突,再次手动解决。也就是说冲突会阻塞多个commit的cherry-pick,它不会一次性合并所有commit,让你一次性解决冲突。具体流程如下:

  • cherry-pick id1 id2 id3 id4 .... idn
  • 冲突报错,到源码手动解决
  • git add -A 添加解决冲突后的文件到.git
  • cherry-pick --continue 继续后面的合并,cherry-pick成功会自动提交commit信息
  • 再遇到冲突,再次解决….
  • 所有id1 … idn全部pick完成

批量cherry-pick每次成功后都会有一次commit信息,有时候会报错,需要手动commit之后再continue

特殊的冲突情况

提示有一个commit是合并的提交,即这个提交是两个分支的交汇,cherry-pick不知道以哪个分支为准
image-20221205104005491
image-20221205104017399

如何解决:cherry-pick添加-m 1选项

For example, if your commit tree is like below:

- A - D - E - F -   master
   \     /
    B - C           branch one
then git cherry-pick E will produce the issue you faced.

git cherry-pick E -m 1 means using D-E, while git cherry-pick E -m 2 means using B-C-E

例如选择cherry-pick commid_id -m 1, 结果如下,可手动解决冲突了
image-20221205104053730
注意有merge的commit,会包含其他人的更新,如果只是pick自己的代码,不需要pick带merge的commit.

跨仓库合并代码

假设某公司windows driver主线仓库为storport, 为了某产品定制的driver仓库为gg8, 现在gg8的所有feature已充分测试,准备合并到主线仓库storport, 这两个仓库的代码差异非常大,维护者众多,如何处理?

首先划分代码各部分归谁负责:
每个人用git,找出其在gg8仓库的个人修改,用winmerge手动合并到主线仓库storport
那么具体如何高效,可靠的合并:

git部分:
用git只找差异部分,具体操作:

git diff commit_a commit_b //找所有文件+代码差异
git diff commit_a commit_b --stat //只显示有差异的文件名,这个信息对应winmerge手动合并很重要
git diff commit_a commit_b 指定文件路径 //只显示指定文件的内容差异,这个信息对应winmerge手动合并很重要

winmerge部分:
winmerge可以比较两个仓库所有差异,但是有些差异可能不需要合并,例如换行,修改时间等。总之winmerge的差异有很多“误报”
如果只一个个打开有差异的文件去比效率太低,需要借助git定位到哪些该开发者负责的文件有改变,以及文件内哪些代码是该开发者改变的。

找出某开发者A的提交改了哪些文件:
image-20221205104623191

找出具体代码:

winmerge直接合并:
只是一句打印差异,但是如果不用git先定位,要从左侧差异栏找出此代码,相当困难
image-20221205104642794

这样,开发者A在代码合并过程中,完全不受其他开发者B, C的差异代码干扰

强制覆盖本地代码

git本地代码有时checkout到旧版本代码,想回到最新版本时,直接pull无法成功,且强制pull也不行。有以下两种方式解决:

重新克隆

最简单是直接删掉本地项目,再重新git clone

fetch覆盖

git fetch --all //拉取远程repo所有branch到本地,但不合并到本地repo
git reset --hard origin/master //本地repo强制同步远程repo的master分支
git pull -f //强制拉取远程repo最新代码

注意,如果本地旧版本代码有xxx.c,而远程最新代码没这个文件,本地需要手动删掉这个文件。因为以上操作不会删除本地文件,只会拉取本地没有的,或者覆盖不同的文件到本地。为了确保旧版本多出的文件删除,直接删除目录下除了.git以外的所有项目文件,再fetch,reset,pull

将本地未初始化git的项目上传到远程已初始化的git仓库

有一些项目代码是基于开源的庞大项目基础上开发,例如UEFI EDK2, Linux kernel.

项目开发时,可能基于不同的开源项目版本,例如:

远程git仓库是EDK2版本A0 + 自定义功能B0;本地的新功能是基于EDK2版本A1 + 自定义功能B1,且本地项目还没有初始化git。这种情况如何将本地项目直接上传到远程已有的项目上面去?

1.首先在本地建立git仓库

在本地新项目目录初始化git仓库:

1
2
3
git init
git add .
git commit -m "commit信息"

2.将本地git仓库关联到远程已有的git仓库

1
git remote add origin http://远程仓库地址.git

3.拉取远程仓库到本地 (如果远程仓库为空不需要此步)

注意--allow-unrelated-histories是忽略本地项目和远程项目没有历史关联的关键参数,否则不能pull成功

1
git pull origin master --allow-unrelated-histories

合并代码通常会有冲突,手动解决冲突后再git add, git commit -m "fix merge conflict"

4.最后推送本地仓库到远程

1
git push -u origin master

链接外部repo作为子模块

在github的项目仓库中,通常看到如下有@符号的外部仓库链接,点进去可能打开其他的项目仓库。这种外部仓库相当于当前项目仓库的子模块。

类似于Linux的软链接,子模块方式可以链接到其他项目仓库,并自动同步其他仓库最新的代码。

image-20221209110659056

1.如何创建外部repo的链接:

1
git submodule add "外部repo地址.git" 外部repo文件夹名

本地就clone了外部仓库到外部repo文件夹名中, 提交本项目和正常的提交流程相同

2.如何clone带外部repo的项目:

git clone 的时候需要加上--recursive,否则外部repo文件夹是空文件夹

1
git clone --recursive "项目地址.git"

如果已经忘记加--recursive,可以手动初始化子模块

1
git submodule update --init --recursive

Git的常用配置

配置多组用户信息

git上传代码时会提交用户信息,包括姓名和邮箱,这个配置是本地的git配置文件决定。

如果要按项目配置多组用户信息,例如公司的代码以公司邮箱提交到公司内部的gitlab,个人项目的代码以个人邮箱提交到github,如何配置?

下面分别介绍全局配置、按项目配置和按文件目录配置三种git配置方法。

(1)git配置文件的位置

git配置文件为.gitconfig。对于windows, 一般在’C:\Users\用户名‘目录下,可以用everything查找.gitconfig,对于Linux, 一般在home目录。本文以windows为例。

(2)全局配置

.gitconfig里面默认的user字段就是全局的配置,首次使用git提交会提示用户输入此信息。

1
2
3
[user]
name = youName
email = youEmail@example.com

全局配置的查看和修改使用--global

1
2
3
4
git config --global user.name                           // 查询全局用户名
git config --global user.name youName // 修改全局用户名
git config --global user.email // 查询全局邮箱
git config --global user.email youEmail@example.com // 修改全局邮箱

(3)对某个git项目自定义配置

这种方法的作用域只是某一个git项目,用的比较少

1
2
3
4
git config user.name                           // 查询项目用户名
git config user.name youName // 修改项目用户名
git config user.email // 查询项目邮箱
git config user.email youEmail@example.com // 修改项目邮箱

(3)对某个路径下的所有git项目自定义配置

git的Conditional Includes可以针对文件夹配置,在.gitconfig添加如下格式的includeIf字段

1
2
[includeIf "gitdir:path/to/you/gitdir/"]
path = ~/.gitconfig_self

其中path/to/you/gitdir/是要自定义配置的路径,可以包含很多git项目。注意尾部必须要加/

.gitconfig_self是自定义配置的gitconfig文件,在里面指定[user]字段

例如我的自定义路径是F:/github-my, 自定义配置文件.gitconfig_mygithub,配置如下:

1
2
[includeIf "gitdir:F:/github-my/"]
path = C:/Users/thomas.hu/.gitconfig_mygithub

注意windows上不能直接右键创建只有后缀名的文件,会提示“必须键入文件名”。

使用CMD或Powershell的命令行创建.gitconfig_mygithub:

1
2
cd C:\Users\thomas.hu
echo > .gitconfig_mygithub

.gitconfig_mygithub定义我个人项目的信息,内容如下:

1
2
3
[user]
name = cursorhu
email = 2449055512@qq.com

全局的.gitconfig是公司项目信息,内容如下:

1
2
3
[user]
name = thomas.hu
email = thomas.hu@xxx.com

配置.gitconfig_mygithub完成后可见两种配置都生效:

1
2
3
4
> git config --global user.name
thomas.hu
> git config user.name
cursorhu

(4)三种配置文件的优先级

git使用以上三种配置的优先级为:项目配置 > 路径配置 > 全局配置

换行符的配置

为了解决跨平台的文件换行符问题,git支持自定义配置换行符规则。

(1)跨平台的文件换行符的相关背景

在各操作系统下,文本文件所使用的换行符是不一样的。UNIX/Linux 使用的是 0x0A(LF),早期的 Mac OS 使用的是 0x0D(CR),后来的 OS X 版本与 UNIX 保持一致了。但 DOS/Windows 使用 0x0D0A(CRLF)作为换行符。也就是说,在不同平台上写代码,其代码文件和一些项目配置文件的换行不一样。

(2)Git工具的autocrlf

Git最开始只支持类Unix的LF换行符,为了支持Windows开发的CRLF换行,Git提供了autocrlf 配置字段autocrlf 。

如果autocrlf enable, Windows 本地的CRLF文件在提交到git时,自动转换为LF换行;从git checkout文件到windows本地时,git将LF换行自动替换为 Windows 的换行符(CRLF)。Linux环境下checkout时文件换行也自动转换为Linux的LF格式。

如果autocrlf disable, Windows 本地的CRLF文件在提交到git时仍然为CRLF换行,如果有其他Linux环境的开发者checkout文件,可能无法在Linux上识别相关的CRLF文件引起项目编译问题。

如果没有跨平台的开发环境,即所有开发者都是Windows或都是Linux环境,则不需要autocrlf enable。

注意:对于windows版本的git, 默认是enable autocrlf,.gitconfig内容如下:

1
2
[core]
autocrlf = true

可以使用命令修改:

1
git config --global core.autocrlf false

(3)同一个项目内,要同时支持LF和CRLF如何设置?

如果是临时的解决某些文件的换行问题,可以手动转换:

1
2
dos2unix #转换dos换行符为unix换行
unix2dos #转换unix换行符为dos换行

对git项目的配置,参考.gitattributes,可以指定某路径的某文件使用指定的换行:

1
2
3
4
*           text=auto		#set autocrlf manually for all files
*.vcproj text eol=crlf #all .vcproj files have CRLF
*.sh text eol=lf #all .sh files have LF
*.jpg -text #prevent .jpg files from being normalized

eol attribute sets a specific line-ending style to be used in the working directory. This attribute has effect only if the text attribute is set or unspecified

一个项目既有windows的bat脚本又有Linux的sh脚本,在全局的core.autocrlf 为true的基础上,配置如下attribute 使这些文件不自动转换:

1
2
3
*.bat	text eol=crlf
*.sh text eol=lf
*/*.cfg text eol=crlf

合并时有二进制文件冲突如何处理

Git merge产生冲突:对于文本文件的冲突有强制处理要求,不解决完冲突无法提交;而对二进制文件的冲突只有提醒,没有强制处理要求。

对于文本文件,提交者可以直接修改冲突的内容;而对于二进制文件,提交者不能直接修改二进制冲突的内容,很容易漏过对二进制文件冲突的处理。

二进制文件冲突,git处理不了内容,应该使用覆盖的方式处理。手动覆盖可以解决此问题,但更合理的方法是用git命令自动解决。

git命令方法:在冲突发生后,使用命令git checkout --ours|--theirs <Paths>来选择是使用“Ours,即当前分支”的二进制文件,还是“Theirs,即合并进来的分支”的二进制文件直接替换掉本地的冲突二进制文件,其中<Paths>是冲突二进制文件的路径。

示例如下:

本地版本为branch-A,要合并进来branch-B

1
git merge branch-B --squash

项目中的exe文件产生的冲突:

1
2
3
warning: Cannot merge binary files: fw_parameter_edit_tool/flash_header_parameter_update_tool.exe (HEAD vs. branch-B)
Auto-merging fw_parameter_edit_tool/flash_header_parameter_update_tool.exe
CONFLICT (content): Merge conflict in fw_parameter_edit_tool/flash_header_parameter_update_tool.exe

此时使用checkout –theirs将branch-B版本的二进制覆盖掉本地branch-A版本的二进制。即完成了此exe的合并(使用branch-B版本)

1
2
> git checkout --theirs fw_parameter_edit_tool/flash_header_parameter_update_tool.exe
Updated 1 path from the index

合并完以后用beyond compare确认一下两个版本的exe是完全一致的,也可以用git diff看一下合并前后二进制文件是否有差异。

string

find()

查找指定字符串的位置(下标)

string中find()返回值是字母在母串中的位置(下标记录),如果没有找到,那么会返回一个特别的标记npos。(返回值可以看成是一个int型的数)

//find函数返回类型 size_type
string s("1a2b3c4d5e6f7jkg8h9i1a2b3c4d5e6f7g8ha9i");
int position;
//find 函数 返回jk 在s 中的下标位置
position = s.find("jk");
if (position != s.npos)  //如果没找到,返回一个特别的标志c++中用npos表示
{
    printf("position is : %d\n" ,position);
}
else
{
    printf("Not found the flag\n");
}

##查找某字符首次出现,或最后出现的位置
find_first_of() 和 find_last_of()返回子串出现在母串中的首次出现的位置,和最后一次出现的位置
查找上面示例的’c’的下标:

flag = "c";
position = s.find_first_of(flag);
printf("s.find_first_of(flag) is :%d\n",position);
position = s.find_last_of(flag);
printf("s.find_last_of(flag) is :%d\n",position);

image-20221208171122674

查找某给定位置后的子串的位置

//从字符串s 下标5开始,查找字符串b ,返回b 在s 中的下标
position=s.find("b",5);
cout<<"s.find(b,5) is : "<<position<<endl;

查找所有子串在母串中出现的位置

//查找s 中flag 出现的所有位置。
flag="a";
position=0;
int i=1;
while((position=s.find(flag,position))!=string::npos)
{
    cout<<"position  "<<i<<" : "<<position<<endl;
    position++;
    i++;
}

map与unordered_map的区别

内部实现

map:
map内部实现了一个红黑树(红黑树是非严格平衡二叉搜索树,而AVL是严格平衡二叉搜索树),红黑树具有自动排序的功能,因此map内部的所有元素都是有序的,红黑树的每一个节点都代表着map的一个元素。因此,对于map进行的查找,删除,添加等一系列的操作都相当于是对红黑树进行的操作。map中的元素是按照二叉搜索树(又名二叉查找树、二叉排序树,特点就是左子树上所有节点的键值都小于根节点的键值,右子树所有节点的键值都大于根节点的键值)存储的,使用中序遍历可将键值按照从小到大遍历出来。

unordered_map:
unordered_map内部实现了一个哈希表(也叫散列表,通过把关键码值映射到Hash表中一个位置来访问记录,查找的时间复杂度可达到O(1),其在海量数据处理中有着广泛应用)。因此,其元素的排列顺序是无序的。

优缺点以及适用处

map
优点:有序性,这是map结构最大的优点,其元素的有序性在很多应用中都会简化很多的操作。
红黑树结构:内部实现一个红黑书使得map的很多操作在lgn的时间复杂度下就可以实现,因此效率非常的高

缺点: 空间占用率高,因为map内部实现了红黑树,虽然提高了运行效率,但是因为每一个节点都需要额外保存父节点、孩子节点和红/黑性质,使得每一个节点都占用大量的空间

适用处:对于那些有顺序要求的问题,用map会更高效一些

unordered_map:

优点: 因为内部实现了哈希表,因此其查找速度非常的快
缺点: 哈希表的建立比较耗费时间
适用处:对于查找问题,unordered_map会更加高效一些,因此遇到查找问题,常会考虑一下用unordered_map

总结:
两种map性能分析的内存占用比较,就是红黑树 VS hash表的性能比较, 还是unorder_map占用的内存要高。
但是unordered_map查找的时间复杂度低,执行效率要比map高很多。

使用示例

unordered_map的用法和map是一样的,都提供了 insert,size,count等操作,并且里面的元素也是以pair类型来存贮的。但其内部实现是不同的,对使用者来说不可见。

示例(map_and_unordered.cpp):

#include <iostream>  
#include <unordered_map>  
#include <map>
#include <string>  

using namespace std;  

int main()  
{  
    ////使用{}赋值, 注意:C++11才开始支持括号初始化
    unordered_map<int, string> myMap = {{ 3, "C" },{ 4, "D" }}; 
    //使用[ ]进行单个插入,若已存在键值2,则修改其值
    myMap[1] = "A";  
    myMap.insert(pair<int, string>(2, "B"));//使用insert和pair插入
  
    //遍历输出+迭代器的使用
    //auto自动识别为迭代器类型unordered_map<int,string>::iterator
    auto iter = myMap.begin(); 
    while (iter!= myMap.end())
    {  
        cout << iter->first << "," << iter->second << endl;  
        ++iter;  
    }  
    
    //查找元素并输出+迭代器的使用
    //find()返回一个指向2的迭代器
    auto iterator = myMap.find(2);
    if (iterator != myMap.end())
        cout << endl<< iterator->first << "," << iterator->second << endl;  
     
    return 0;  
}

编译:
image-20221208171339229

结果:
unordered_map:没有按值的大小排序,从最近插入的到最早插入的,依次显示
image-20221208171412418
把unordered_map改成map: 按值的大小,从小到大显示
image-20221208171351937

sort

sort()函数是STL中的排序函数,由模板函数实现,复杂度N*logN。该函数专门用来对容器或普通数组中指定范围内的元素进行排序,该函数使用频率较高,且其实现综合了几种经典排序方法
使用格式如下:

sort (first, last) //排序从first到last的数据,默认从小到大
sort (first, last, rule) //以某种规则排序,rule可使用std定义的,或自定义实现

使用示例

几种典型的使用方式:

  • 默认:从小到大
  • greater< Type >():std提供的从大到小
  • 自定义规则:函数,运算符,Lambda实现,这里规则都是传入两个参数(分别是要比较数组的靠左值,靠右值),返回bool类型,如果左值<右值,即从小到大排序,反之从大到小

代码:

#include <iostream>     // std::cout
#include <algorithm>    // std::sort
#include <vector>       // std::vector
#include <stdlib.h>

using namespace std;

//以普通函数的方式实现自定义排序规则
bool myComp(int i, int j) {
    return (i < j);
}

//以对象的方式实现自定义排序规则
class myCompOper {
public:
    bool operator() (int i, int j) {
        return (i > j);
    }
};

//打印数组
void print_array(std::vector<int> &a, const char *s)
{
    printf("%s\n", s);
    vector<int>::iterator it;
    for (it = a.begin(); it != a.end(); ++it)
    {
        printf("%d ", *it);
    }
    printf("\n");
}

int main() {
    //std::vector<int> array;
    //char num;
    //while(cin.get() != '\n')
    //{
    //    cin >> num;
    //    array.push_back(num);
    //}

    vector<int> array{1,3,4,2,5,7,6,8,9};
    print_array(array, "input array:");

    //默认排序,从小到大
    std::sort(array.begin(), array.end());
    print_array(array, "default sort:");

    //使用STL标准库提供的其它比较规则, 比如 greater<T>,从大到小
    std::sort(array.begin(), array.end(), std::greater<int>());
    print_array(array, "std::greater<T> sort:");

    //自定义比较规则: 普通函数
    std::sort(array.begin(), array.end(), myComp);
    print_array(array, "myComp sort:");

    //自定义比较规则: 类内运算符重载
    std::sort(array.begin(), array.end(), myCompOper());
    print_array(array, "myCompOper sort:");

    //自定义比较规则: Lambda匿名函数
    std::sort(array.begin(), array.end(), [](int i, int j) {return i < j;});
    print_array(array, "Lambda sort:");


return 0;
}

结果如下:
image-20221208171536663

内部实现

STL中的sort并非只是普通的快速排序,除了对普通的快速排序进行优化,它还结合了插入排序和堆排序。根据不同的数量级别以及不同情况,能自动选用合适的排序方法。当数据量较大时采用快速排序,分段递归。一旦分段后的数据量小于某个阀值,为避免递归调用带来过大的额外负荷,便会改用插入排序。而如果递归层次过深,有出现最坏情况的倾向,还会改用堆排序。

(1)普通快排
普通快速排序算法可以叙述如下,假设S代表需要被排序的数据序列:

  • 如果S中的元素只有0个或1个,结束。
  • 取S中的任何一个元素作为枢轴pivot。
  • 将S分割为L、R两端,使L内的元素都小于等于pivot,R内的元素都大于等于pivot。
  • 对L、R递归执行上述过程。

快速排序最关键的地方在于枢轴的选择,最坏的情况发生在分割时产生了一个空的区间,这样就完全没有达到分割的效果。STL采用的做法称为median-of-three,即取整个序列的首、尾、中央三个地方的元素,以其中值作为枢轴。

分割的方法通常采用两个迭代器head和tail,head从头端往尾端移动,tail从尾端往头端移动,当head遇到大于等于pivot的元素就停下来,tail遇到小于等于pivot的元素也停下来,若head迭代器仍然小于tail迭代器,即两者没有交叉,则互换元素,然后继续进行相同的动作,向中间逼近,直到两个迭代器交叉,结束一次分割。

(2)内省式排序 Introsort
不当的枢轴选择,导致不当的分割,会使快速排序恶化为 O(n2)。David R.Musser于1996年提出一种混合式排序算法:Introspective Sorting(内省式排序),简称IntroSort,其行为大部分与上面所说的median-of-three Quick Sort完全相同,但是当分割行为有恶化为二次方的倾向时,能够自我侦测,转而改用堆排序,使效率维持在堆排序的 O(nlgn),又比一开始就使用堆排序来得好。

sort声明:

#include <algorithm>
 
template< class RandomIt >
void sort( RandomIt first, RandomIt last );
 
template< class RandomIt, class Compare >
void sort( RandomIt first, RandomIt last, Compare comp );

sort实现:

template <class _RandomAccessIter>
inline void sort(_RandomAccessIter __first, _RandomAccessIter __last) {
  __STL_REQUIRES(_RandomAccessIter, _Mutable_RandomAccessIterator);
  __STL_REQUIRES(typename iterator_traits<_RandomAccessIter>::value_type,
                 _LessThanComparable);
  if (__first != __last) {
    __introsort_loop(__first, __last,
                     __VALUE_TYPE(__first),
                     __lg(__last - __first) * 2);
    __final_insertion_sort(__first, __last);
  }
}

__introsort_loop便是上面介绍的内省式排序,其第三个参数中所调用的函数__lg()便是用来控制分割恶化情况,求lg(n)(取下整),意味着快速排序的递归调用最多 2*lg(n) 层。
__lg()实现如下

template <class Size>
inline Size __lg(Size n) {
    Size k;
    for (k = 0; n > 1; n >>= 1) ++k;
    return k;
}

__introsort_loop实现:

  • 首先判断元素规模是否大于阀值__stl_threshold,__stl_threshold是一个常整形的全局变量,值为16,表示若元素规模小于等于16,则结束内省式排序算法,返回sort函数,改用插入排序。
  • 若元素规模大于__stl_threshold,则判断递归调用深度是否超过限制。若已经到达最大限制层次的递归调用,则改用堆排序。代码中的partial_sort即用堆排序实现。
  • 若没有超过递归调用深度,则调用函数__unguarded_partition()对当前元素做一趟快速排序,并返回枢轴位置。
  • 经过一趟快速排序后,再递归对右半部分调用内省式排序算法。然后回到while循环,对左半部分进行排序。源码写法和我们一般的写法不同,但原理是一样的,需要注意。

递归上述过程,直到元素规模小于__stl_threshold,然后返回sort函数,对整个元素序列调用一次插入排序,此时序列中的元素已基本有序,所以插入排序也很快。至此,整个sort函数运行结束。

__introsort_loop代码:

template <class _RandomAccessIter, class _Tp, class _Size>
void __introsort_loop(_RandomAccessIter __first,
                      _RandomAccessIter __last, _Tp*,
                      _Size __depth_limit)
{
  while (__last - __first > __stl_threshold) {
    if (__depth_limit == 0) {
      partial_sort(__first, __last, __last);
      return;
    }
    --__depth_limit;
    _RandomAccessIter __cut =
      __unguarded_partition(__first, __last,
                            _Tp(__median(*__first,
                                         *(__first + (__last - __first)/2),
                                         *(__last - 1))));
    __introsort_loop(__cut, __last, (_Tp*) 0, __depth_limit);
    __last = __cut;
  }
}

__unguarded_partition()函数

template <class _RandomAccessIter, class _Tp>
_RandomAccessIter __unguarded_partition(_RandomAccessIter __first, 
                                        _RandomAccessIter __last, 
                                        _Tp __pivot) 
{
    while (true) {
        while (*__first < __pivot)
            ++__first;
        --__last;
        while (__pivot < *__last)
            --__last;
        if (!(__first < __last))
            return __first;
        iter_swap(__first, __last);
        ++__first;
    }
}

参考: 《STL源码剖析》–侯捷

0.前言

相信大家面试都逃不开设计模式话题,本节将阐述面试中的最常用的设计模式(单例模式),从分类,线程安全,不基于C++11标准的角度与基于C++11标准的角度,有哪些解决线程安全的单例模式方案,相信认真看完本篇文章,在以后面试中就不用担忧了。

众所周知的单例:
在一般书籍中或者大家比较是熟知的单例模式是下面这样:

class singleton {
private:
    singleton() {}
    static singleton *p;
public:
    static singleton *instance();
};

singleton *singleton::p = nullptr;

singleton* singleton::instance() {
    if (p == nullptr)
        p = new singleton();
    return p;
}

这是一个非常简单的实现,将构造函数声明为private或protect防止被外部函数实例化,内部有一个静态的类指针保存唯一的实例,实例的实现由一个public方法来实现,该方法返回该类的唯一实例。

当然这个代码只适合在单线程下,当多线程时,是不安全的。考虑两个线程同时首次调用instance方法且同时检测到p是nullptr,则两个线程会同时构造一个实例给p,这将违反了单例的准则。

2.懒汉与饿汉

单例分为两种实现方法:

懒汉:第一次用到类实例的时候才会去实例化,上述就是懒汉实现。
饿汉:单例类定义的时候就进行了实例化。

这里也给出饿汉的实现:

class singleton {
private:
    singleton() {}
    static singleton *p;
public:
    static singleton *instance();
};

singleton *singleton::p = new singleton();
singleton* singleton::instance() {
    return p;
}

当然这个是线程安全的,对于我们通常阐述的线程不安全,为懒汉模式,下面会阐述懒汉模式的线程安全代码优化。

3.多线程加锁

在C++中加锁有个类实现原理采用RAII,不用手动管理unlock,那就是lock_guard,这里采用其进行加锁。

class singleton {
private:
    singleton() {}
    static singleton *p;
    static mutex lock_;
public:
    static singleton *instance();
};

singleton *singleton::p = nullptr;

singleton* singleton::instance() {
    lock_guard<mutex> guard(lock_);
    if (p == nullptr)
        p = new singleton();
    return p;
}

这种写法不会出现上面两个线程都执行到p=nullptr里面的情况,当线程A在执行p = new Singleton()的时候,线程B如果调用了instance(),一定会被阻塞在加锁处,等待线程A执行结束后释放这个锁。从而是线程安全的。

但是这种写法性能非常低下,因为每次调用instance()都会加锁释放锁,而这个步骤只有在第一次new Singleton()才是有必要的,只要p被创建出来了,不管多少线程同时访问,使用if (p == nullptr)进行判断都是足够的(只是读操作,不需要加锁),没有线程安全问题,加了锁之后反而存在性能问题。

因此引出**双重检查锁(DCL)**。

4.双重检查锁模式

上面写法是不管任何情况都会去加锁,然后释放锁,而对于读操作是不存在线程安全的,故只需要在第一次实例创建的时候加锁,以后不需要。下面先看一下DCLP的实现:

singleton* singleton::instance() {
    if(p == nullptr) {  // 第一次检查
        Lock lock;
        if(p == nullptr){ // 第二次检查
            p = new singleton;
        }
    }
    return p;
}

基于上述,我们可以写出双重检查锁+自动回收(DCLP)

class singleton {
private:
    singleton() {}

    static singleton *p;
    static mutex lock_;
public:
    singleton *instance();

    // 实现一个内嵌垃圾回收类
    class CGarbo
    {
    public:
        ~CGarbo()
        {
            if(singleton::p)
                delete singleton::p;
        }
    };
    static CGarbo Garbo; // 定义一个静态成员变量,程序结束时,系统会自动调用它的析构函数从而释放单例对象
};

singleton *singleton::p = nullptr;
singleton::CGarbo Garbo;

singleton* singleton::instance() {
    if (p == nullptr) {
        lock_guard<mutex> guard(lock_);
        if (p == nullptr)
            p = new singleton();
    }
    return p;
}

DCLP的关键在于,大多数对instance的调用会看到p是非空的,因此甚至不用尝试去初始化它。因此,DCLP在尝试获取锁之前检查p是否为空。只有当检查成功(也就是p还没有被初始化)时才会去获得锁,然后再次检查p是否仍然为空(因此命名为双重检查锁)。第二次检查是必要,因为就像我们刚刚看到的,很有可能另一个线程偶然在第一次检查之后,获得锁成功之前初始化p。

看起来上述代码非常美好,可是过了相当一段时间后,才发现这个漏洞,原因是:内存读写的乱序执行(编译器问题)。

再次考虑初始化p的那一行:

p = new singleton;

这条语句会导致三个事情的发生:

  • 分配能够存储singleton对象的内存;
  • 在被分配的内存中构造一个singleton对象;
  • 让p指向这块被分配的内存。

可能会认为这三个步骤是按顺序执行的,但实际上只能确定步骤1是最先执行的,步骤2,3却不一定。问题就出现在这。

  1. 线程A调用instance,执行第一次p的测试,获得锁,按照1,3,执行,然后被挂起。此时p是非空的,但是p指向的内存中还没有Singleton对象被构造。
  2. 线程B调用instance,判定p非空,
  3. 将其返回给instance的调用者。调用者对指针解引用以获得singleton,噢,一个还没有被构造出的对象。bug就出现了。

DCLP能够良好的工作仅当步骤一和二在步骤三之前被执行,但是并没有并没有方法在C或C++中表达这种限制。这就像是插在DCLP心脏上的一把匕首:我们需要在相对指令顺序上定义限制,但是我们的语言没有给出表达这种限制的方法。

5.memory barrier指令

DCLP问题在C++11中,这个问题得到了解决。

因为新的C++11规定了新的内存模型,保证了执行上述3个步骤的时候不会发生线程切换,相当这个初始化过程是“原子性”的的操作,DCL又可以正确使用了,不过在C++11下却有更简洁的多线程singleton写法了,这个留在后面再介绍。

C++11之前解决方法是barrier指令。要使其正确执行的话,就得在步骤2、3直接加上一道memory barrier。强迫CPU执行的时候按照1、2、3的步骤来运行。

第一种实现:

基于operator new + placement new,遵循1,2,3执行顺序依次编写代码。

// method 1 operator new + placement new
singleton *instance() {
    if (p == nullptr) {
        lock_guard<mutex> guard(lock_);
        if (p == nullptr) {
            singleton *tmp = static_cast<singleton *>(operator new(sizeof(singleton)));
            new(p)singleton();
            p = tmp;
        }
    }
    return p;
}

第二种实现:

基于直接嵌入ASM汇编指令mfence,uninx的barrier宏也是通过该指令实现的。

#define barrier() __asm__ volatile ("lwsync")
singleton *singleton::instance() {
    if (p == nullptr) {
        lock_guard<mutex> guard(lock_);
        barrier();
        if (p == nullptr) {
            p = new singleton();
        }
    }
    return p;
}

通常情况下是调用cpu提供的一条指令,这条指令的作用是会阻止cpu将该指令之前的指令交换到该指令之后,这条指令也通常被叫做barrier。

上面代码中的asm表示这个是一条汇编指令,volatile是可选的,如果用了它,则表示向编译器声明不允许对该汇编指令进行优化。lwsync是POWERPC提供的barrier指令。

6.静态局部变量

Scott Meyer在《Effective C++》中提出了一种简洁的singleton写法

singleton *singleton::instance() {
    static singleton p;
    return &p;
}
  • 单线程下,正确。
  • C++11及以后的版本(如C++14)的多线程下,正确。
  • C++11之前的多线程下,不一定正确。

原因在于在C++11之前的标准中并没有规定local static变量的内存模型。于是乎它就是不是线程安全的了。但是在C++11却是线程安全的,这是因为新的C++标准规定了当一个线程正在初始化一个变量的时候,其他线程必须得等到该初始化完成以后才能访问它。

上述使用的内存序:

memory_order_relaxed:松散内存序,只用来保证对原子对象的操作是原子的
memory_order_acquire:获得操作,在读取某原子对象时,当前线程的任何后面的读写操作都不允许重排到这个操作的前面去,并且其他线程在对同一个原子对象释放之前的所有内存写入都在当前线程可见
memory_order_release:释放操作,在写入某原子对象时,当前线程的任何前面的读写操作都不允许重排到这个操作的后面去,并且当前线程的所有内存写入都在对同一个原子对象进行获取的其他线程可见

7.Atomic

在C++11之前的版本下,除了通过锁实现线程安全的Singleton外,还可以利用各个编译器内置的atomic operation来实现。

java和c#发现乱序问题后,就加了一个关键字volatile,在声明p变量的时候,要加上volatile修饰,编译器看到之后,就知道这个地方不能够reorder(一定要先分配内存,在执行构造器,都完成之后再赋值)。

而对于c++标准却一直没有改正,所以VC++在2005版本也加入了这个关键字,但是这并不能够跨平台(只支持微软平台)。

而到了c++ 11版本,为了从根本上消除这些漏洞,引入了适合多线程的内存模型。终于有了这样的机制帮助我们实现跨平台的方案。

mutex singleton::lock_;
atomic<singleton *> singleton::p;

/*
* std::atomic_thread_fence(std::memory_order_acquire); 
* std::atomic_thread_fence(std::memory_order_release);
* 这两句话可以保证他们之间的语句不会发生乱序执行。
*/
singleton *singleton::instance() {
    singleton *tmp = p.load(memory_order_relaxed);
    atomic_thread_fence(memory_order_acquire);
    if (tmp == nullptr) {
        lock_guard<mutex> guard(lock_);
        tmp = p.load(memory_order_relaxed);
        if (tmp == nullptr) {
            tmp = new singleton();
            atomic_thread_fence(memory_order_release);
            p.store(tmp, memory_order_relaxed);
        }
    }
    return p;
}

值得注意的是,上述代码使用两个比较关键的术语,获得与释放:

  • 获得是一个对内存的读操作,当前线程的任何后面的读写操作都不允许重排到这个操作的前面去。
  • 释放是一个对内存的写操作,当前线程的任何前面的读写操作都不允许重排到这个操作的后面去。

acquire 和 release 通常都是配对出现的,目的是保证如果对同一个原子对象的 release 发生在 acquire 之前的话,release 之前发生的内存修改能够被 acquire 之后的内存读取全部看到。

8.pthread_once

如果是在unix平台的话,除了使用atomic operation外,在不适用C++11的情况下,还可以通过pthread_once来实现Singleton。

原型如下:

int pthread_once(pthread_once_t once_control, void (init_routine) (void));

实现:

class singleton {
private:
    singleton(); //私有构造函数,不允许使用者自己生成对象
    singleton(const singleton &other);

    //要写成静态方法的原因:类成员函数隐含传递this指针(第一个参数)
    static void init() {
        p = new singleton();
    }

    static pthread_once_t ponce_;
    static singleton *p; //静态成员变量 
public:
    singleton *instance() {
        // init函数只会执行一次
        pthread_once(&ponce_, &singleton::init);
        return p;
    }
};

9.总结

本文讲解了几种单例模式,并讲解了线程安全的单例模式,以及不用C++11实现的几种线程安全的单例模式:memory barrier,静态局部变量,pthread_once方式,C++11的atomic实现等。

最后值得注意的是,针对上述单例类的析构函数请参考双重锁检查模式+自动回收实现,必须在类中声明一个静态局部变量,静态局部变量可以理解为全局变量,在程序结束时,自动调用该静态局部变量的析构函数,这就是为什么要在类中声明与定义嵌套类,而不是直接编写单例的析构函数。

本文参考:C++那些事->设计模式->单例模式

Linux虚拟内存空间分布

(1)虚拟内存空间与物理内存:
带MMU控制器的CPU支持将物理内存以分页的方式,细粒度的动态分配给进程,使每个进程只看得到这个虚拟的内存空间,每个进程认为自己可以访问整个内存空间。进程根本不知道其访问的某个内存页的实际物理地址,也许在SDRAM上,或者硬盘的交换分区上。

进程的虚拟地址通过页表(page table)映射到物理内存,页表由操作系统维护并被处理器引用。每个进程都拥有一套属于它自己的页表。

(2)下面讨论用户进程能看到什么样的虚拟内存空间:

以32位系统为例,CPU可寻址4GB的内存空间。此时虚拟地址空间范围为0~4G,Linux内核将这4G字节的空间分为两部分:

  • 将最高的1G字节(从虚拟地址0xC0000000到0xFFFFFFFF)供内核使用,称为“内核空间”。
  • 将较低的3G字节(从虚拟地址0x00000000到0xBFFFFFFF)供各个进程使用,称为“用户空间

因为每个进程可以通过系统调用进入内核,因此,Linux内核由系统内的所有进程共享。从具体进程的角度来看,每个进程可以拥有4G字节的虚拟空间。

image-20221205115648795

注意:

  • 内核可见的内存空间只有全局的1GB; 用户进程可见的内存空间包括该进程独有的3GB空间,和全局内核的1GB;
  • 用户进程虽然可见内核空间的1GB,但不可直接访问,要通过系统调用(或中断等方式),涉及上下文切换;
  • 当进程访问内核空间时,称为“进入内核态”,返回时称为“进入用户态”;
  • 内核空间分布在虚拟内存空间的高地址,用户空间在低地址

(3)用户进程的内部空间详解

编译好的程序都分为几个段(section),在程序运行过程中的临时变量还产生堆栈,程序手动分配的内存使用堆, 还有命令行参数和环境变量等配置信息,这些东西都属于进程空间的数据。

image-20221205115908003

详解如下:
代码段(Text):存放程序指令,一些只读数据(.rodata)也可归为此类
数据段(Data):存放初始化过的全局数据
BSS段:存放未初始化(默认为0)的全局数据
栈 (Stack): 用于控制函数调用和返回过程中的临时变量,存储函数内的临时变量; 存储函数的返回指针,
堆 (Heap):存储动态内存分配, 需要程序员手工分配, 手工释放。注意与数据结构中的堆(优先队列)是不同,分配方式类似于链表。

Linux进程间通信(IPC)

进程本身是为了隔离程序的资源,但不同程序间可能有数据通信或调用关系,因此需要进程通信机制。

进程通信最主要的几种方式有:管道(pipe) , 共享内存(shared memory), 消息队列(message queue), socket等。为了进程间的时序同步和资源处理,信号量(semaphore)通常配合使用。

本节重点讲管道和共享内存,关于Linux IPC 的全面内容,参考:
An introduction to Linux IPC
inter-process_communication_in_linux

进程通信的基本思路

根据上节的内存空间分布,所有进程共享同一个内核空间,最简单的进程通信就是通过 进程A->内核->进程B:
1637063328269_12

以上虽然可以实现,但有两次拷贝以及上下文切换,其总体思路是管道和共享内存方式的基础。

管道

管道的实质就是一个内核缓冲区;
管道对于管道两端的进程而言就是一个文件,与普通文件的区别是管道只存在于内存中;
进程通过读写管道文件,传递数据;

管道依据是否有名字分为匿名管道和命名管道,其功能有以下区别:
匿名管道(通常管道就是指匿名管道):

  • 半双工的,即管道设置好后,数据只能从进程A到进程B;如果还需要从B到A,需要创建另外的管道
  • 只能用于父子进程或兄弟进程之间的通信

命名管道(FIFO):

  • 可用于无关联进程的通信,其基本原理和匿名管道一样,本节不详细描述

管道内部提供了同步机制
临界资源: 大家都能访问到的共享资源
临界区: 对临界资源进行操作的代码
同步: 临界资源访问的可控时序性(一个操作完另一个才可以操作)
互斥: 对临界资源同一时间的唯一访问性(保护临界资源安全)

(匿名)管道使用三部曲

1.创建本进程的管道
使用pipe函数创建管道文件
image-20221205115729244

2.fork子进程,共享管道
image-20221205115734973

3.设置管道为单向
image-20221205115744442

共享内存

Linux中每个进程都有属于自己的进程控制块(PCB)和地址空间(Addr Space),并且都有一个与之对应的页表,负责将进程的虚拟地址与物理地址进行映射,通过内存管理单元(MMU)进行管理。

两个不同的虚拟地址通过页表映射到物理空间的同一区域,它们所指向的这块区域即共享内存。

共享内存的通信原理:
image-20221205115751869

共享内存的关键是一份内存资源被两个进程占用,因此需要信号量等同步机制,实现进程同步与资源互斥。

这里简单说明我对信号量的理解:

  • 信号量的作用是“流程同步”,这个流程可以是两个进程访问共享内存,也可以是同一进程内的多个线程访问共享数据;
  • 注意,信号量并不一定用于共享资源的情景,可能只是简单的主线程等待工作线程这种情况。这是其和互斥锁的关键区别;
  • 信号量如果用于共享资源,其本质是“引用计数”,即共享资源是否可用的计数,计数为0表示无资源可用。各进程如果获得资源计数-1,释放资源计数+1。

参考文章

Linux进程地址空间和进程的内存分布
An introduction to Linux IPC
inter-process_communication_in_linux
Linux 进程间通信(IPC)总结

本文将从以下几个方面来阐述信号:

(1) 信号的基本知识
(2) 信号生命周期与处理过程分析
(3) 基本的信号处理函数
(4) 保护临界区不被中断
(5) 信号的继承与执行
(6) 实时信号中锁的研究

第一部分: 信号的基本知识

1.信号本质:

信号的本质是软件层次上对中断的一种模拟。它是一种异步通信的处理机制,事实上,进程并不知道信号何时到来。

2.信号来源

(1)程序错误,如非法访问内存
(2)外部信号,如按下了CTRL+C
(3)通过kill或sigqueue向另外一个进程发送信号

3.信号种类

信号分为可靠信号与不可靠信号,可靠信号又称为实时信号,非可靠信号又称为非实时信号。
信号代码从1到32是不可靠信号,不可靠信号主要有以下问题:
(1)每次信号处理完之后,就会恢复成默认处理,这可能是调用者不希望看到的
(2)存在信号丢失的问题
现在的Linux对信号机制进行了改进,因此,不可靠信号主要是指信号丢失
信号代码从SIGRTMIN到SIGRTMAX之间的信号是可靠信号。可靠信号不存在丢失,由sigqueue发送,可靠信号支持排队。

可靠信号注册机制:
内核每收到一个可靠信号都会去注册这个信号,在信号的未决信号链中分配sigqueue结构,因此,不会存在信号丢失的问题。

不可靠信号的注册机制:
而对于不可靠的信号,如果内核已经注册了这个信号,那么便不会再去注册,对于进程来说,便不会知道本次信号的发生。
可靠信号与不可靠信号与发送函数没有关系,取决于信号代码,前面的32种信号就是不可靠信号,而后面的32种信号就是可靠信号。

4.信号响应的方式

(1)采用系统默认处理SIG_DFL,执行缺省操作
(2)捕捉信号处理,即用户自定义的信号处理函数来处理
(3)忽略信号SIG_IGN ,但有两种信号不能被忽略SIGKILL,SIGSTOP

第二部分: 信号的生命周期与处理过程分析

1. 信号的生命周期

信号产生->信号注册->信号在进程中注销->信号处理函数执行完毕

(1)信号的产生是指触发信号的事件的发生

(2)信号注册
指的是在目标进程中注册,该目标进程中有未决信号的信息:

struct sigpending pending:
struct sigpending{
struct sigqueue *head, **tail;
sigset_t signal;
};

struct sigqueue{
struct sigqueue *next;
siginfo_t info;
}

其中 sigqueue结构组成的链称之为未决信号链,sigset_t称之为未决信号集。
*head,**tail分别指向未决信号链的头部与尾部。
siginfo_t info是信号所携带的信息。
信号注册的过程就是将信号值加入到未决信号集siginfo_t中,将信号所携带的信息加入到未决信号链的某一个sigqueue中去。
因此,对于可靠的信号,可能存在多个未决信号的sigqueue结构,对于每次信号到来都会注册。而不可靠信号只注册一次,只有一个sigqueue结构。
只要信号在进程的未决信号集中,表明进程已经知道这些信号了,还没来得及处理,或者是这些信号被阻塞。

(3)信号在目标进程中注销
在进程的执行过程中,每次从系统调用或中断返回用户空间的时候,都会检查是否有信号没有被处理。如果这些信号没有被阻塞,那么就调用相应的信号处理函数来处理这些信号。则调用信号处理函数之前,进程会把信号在未决信号链中的sigqueue结构卸掉。是否从未决信号集中把信号删除掉,对于实时信号与非实时信号是不相同的。
非实时信号:由于非实时信号在未决信号链中只有一个sigqueue结构,因此将它删除的同时将信号从未决信号集中删除。
实时信号:由于实时信号在未决信号链中可能有多个sigqueue结构,如果只有一个,也将信号从未决信号集中删除掉。如果有多个那么不从未决信号集中删除信号,注销完毕。

(4)信号处理函数执行完毕
执行处理函数,本次信号在进程中响应完毕。
在第4步,只简单的描述了信号处理函数执行完毕,就完成了本次信号的响应,但这个信号处理函数空间是怎么处理的呢? 内核栈与用户栈是怎么工作的呢? 这就涉及到了信号处理函数的过程。

2. 信号处理函数的过程:

(1)注册信号处理函数
信号的处理是由内核来代理的,首先程序通过sigal或sigaction函数为每个信号注册处理函数,而内核中维护一张信号向量表,对应信号处理机制。这样,在信号在进程中注销完毕之后,会调用相应的处理函数进行处理。

(2)信号的检测与响应时机
在系统调用或中断返回用户态的前夕,内核会检查未决信号集,进行相应的信号处理。

(3)处理过程:
程序运行在用户态时->进程由于系统调用或中断进入内核->转向用户态执行信号处理函数->信号处理函数完毕后进入内核->返回用户态继续执行程序
首先程序执行在用户态,在进程陷入内核并从内核返回的前夕,会去检查有没有信号没有被处理,如果有且没有被阻塞就会调用相应的信号处理程序去处理。首先,内核在用户栈上创建一个层,该层中将返回地址设置成信号处理函数的地址,这样,从内核返回用户态时,就会执行这个信号处理函数。当信号处理函数执行完,会再次进入内核,主要是检测有没有信号没有处理,以及恢复原先程序中断执行点,恢复内核栈等工作,这样,当从内核返回后便返回到原先程序执行的地方了。
信号处理函数的过程大概是这样了。
具体的可参考http://www.spongeliu.com/linux/linux内核信号处理机制介绍/

第三部分: 基本的信号处理函数

首先看一个两个概念: 信号未决与信号阻塞
信号未决: 指的是信号的产生到信号处理之前所处的一种状态。确切的说,是信号的产生到信号注销之间的状态。
信号阻塞: 指的是阻塞信号被处理,是一种信号处理方式。

1. 信号操作

信号操作最常用的方法是信号的屏蔽,信号屏蔽主要用到以下几个函数:

int sigemptyset(sigset_t *set);
int sigfillset(sigset_t *set);
int sigaddset(sigset_t *set,int signo);
int sigdelset(sigset_t *set,int signo);
int sigismemeber(sigset_t* set,int signo);
int sigprocmask(int how,const sigset_t*set,sigset_t *oset);

信号集,信号掩码,未决集
信号集: 所有的信号阻塞函数都使用一个称之为信号集的结构表明其所受到的影响。
信号掩码:当前正在被阻塞的信号集。
未决集: 进程在收到信号时到信号在未被处理之前信号所处的集合称为未决集。
可以看出,这三个概念没有必然的联系,信号集指的是一个泛泛的概念,而未决集与信号掩码指的是具体的信号状态。

对于信号集的初始化有两种方法: 一种是用sigemptyset使信号集中不包含任何信号,然后用sigaddset把信号加入到信号集中去。
另一种是用sigfillset让信号集中包含所有信号,然后用sigdelset删除信号来初始化。
sigemptyset()函数初始化信号集set并将set设置为空。
sigfillset()函数初始化信号集,但将信号集set设置为所有信号的集合。
sigaddset()将信号signo加入到信号集中去。
sigdelset()从信号集中删除signo信号。
sigprocmask()将指定的信号集合加入到进程的信号阻塞集合中去。如果提供了oset,那么当前的信号阻塞集合将会保存到oset集全中去。
参数how决定了操作的方式:
SIG_BLOCK 增加一个信号集合到当前进程的阻塞集合中去
SIG_UNBLOCK 从当前的阻塞集合中删除一个信号集合
SIG_SETMASK 将当前的信号集合设置为信号阻塞集合

下面看一个例子:

int main(){
    sigset_t initset;
    int i;
    sigemptyset(&initset);//初始化信号集合为空集合
    sigaddset(&initset,SIGINT);//将SIGINT信号加入到此集合中去
    while(1){
        sigprocmask(SIG_BLOCK,&initset,NULL);//将信号集合加入到进程的阻塞集合中去
        fprintf(stdout,"SIGINT singal blocked/n");
        for(i=0;i<10;i++){
        
            sleep(1);//每1秒输出
            fprintf(stdout,"block %d/n",i);
        }
        //在这时按一下Ctrl+C不会终止
        sigprocmask(SIG_UNBLOCK,&initset,NULL);//从进程的阻塞集合中去删除信号集合
        fprintf(stdout,"SIGINT SINGAL unblokced/n");
        for(i=0;i<10;i++){
            sleep(1);
            fprintf(stdout,"unblock %d/n",i);
        }
    }
    exit(0);
}

执行结果:

SIGINT singal blocked
block 0
block 1
block 2
block 3
block 4
block 5
block 6
block 7
block 8
block 9

在执行到block 3时按下了CTRL+C并不会终止,直到执行到block9后将集合从阻塞集合中移除。

[root@localhost C]# ./s1
SIGINT singal blocked
block 0
block 1
block 2
block 3
block 4
block 5
block 6
block 7
block 8
block 9
SIGINT SINGAL unblokced
unblock 0
unblock 1

由于此时已经解除了阻塞,在unblock1后按下CTRL+C则立即终止。

2. 信号处理函数

sigaction

int sigaction(
    int signo,
    const struct sigaction *act,
    struct sigaction *oldact
);

这个函数主要是用于改变或检测信号的行为。
第一个参数是变更signo指定的信号,它可以指向任何值,SIGKILL,SIGSTOP除外
第二个参数,第三个参数是对信号进行细粒度的控制。
如果act不为空,oldact不为空,那么oldact将会存储信号以前的行为。如果act为空,*oldact不为空,那么oldact将会存储信号现在的行为。

struct sigaction {
    void (*sa_handler)(int);
    void (*sa_sigaction)(int,siginfo_t*,void*);
    sigset_t sa_mask;
    int sa_flags;
    void (*sa_restorer)(void);
}

参数含义:
sa_handler是一个函数指针,主要是表示接收到信号时所要采取的行动。此字段的值可以是SIG_DFL,SIG_IGN.分别代表默认操作与内核将忽略进程的信号。这个函数只传递一个参数那就是信号代码。
当SA_SIGINFO被设定在sa_flags中,那么则会使用sa_sigaction来指示信号处理函数,而非sa_handler.
sa_mask设置了掩码集,在程序执行期间会阻挡掩码集中的信号。
sa_flags设置了一些标志, SA_RESETHAND当该函数处理完成之后,设定为为系统默认的处理模式。SA_NODEFER 在处理函数中,如果再次到达此信号时,将不会阻塞。默认情况下,同一信号两次到达时,如果此时处于信号处理程序中,那么此信号将会阻塞。
SA_SIGINFO表示用sa_sigaction指示的函数。
sa_restorer已经被废弃。

sa_sigaction所指向的函数原型:

void my_handler(int signo,siginfo_t *si,void *ucontext);

第一个参数: 信号编号
第二个参数:指向一个siginfo_t结构。
第三个参数是一个ucontext_t结构。
其中siginfo_t结构体中包含了大量的信号携带信息,可以看出,这个函数比sa_handler要强大,因为前者只能传递一个信号代码,而后者可以传递siginfo_t信息。

typedef struct siginfo_t{
    int si_signo;//信号编号
    int si_errno;//如果为非零值则错误代码与之关联
    int si_code;//说明进程如何接收信号以及从何处收到
    pid_t si_pid;//适用于SIGCHLD,代表被终止进程的PID
    pid_t si_uid;//适用于SIGCHLD,代表被终止进程所拥有进程的UID
    int si_status;//适用于SIGCHLD,代表被终止进程的状态
    clock_t si_utime;//适用于SIGCHLD,代表被终止进程所消耗的用户时间
    clock_t si_stime;//适用于SIGCHLD,代表被终止进程所消耗系统的时间
    sigval_t si_value;
    int si_int;
    void * si_ptr;
    void* si_addr;
    int si_band;
    int si_fd;
};

sigqueue

sigqueue(pid_t pid,int signo,const union sigval value)

sigqueue函数类似于kill,也是一个进程向另外一个进程发送信号的。
但它比kill函数强大。
第一个参数指定目标进程的pid.
第二个参数是一个信号代码。
第三个参数是一个共用体,每次只能使用一个,用来进程发送信号传递的数据。
或者传递整形数据,或者是传递指针。
发送的数据被sa_sigaction所指示的函数的siginfo_t结构体中的si_ptr或者是si_int所接收。

sigpending

sigpending(sigset_t set);

这个函数的作用是返回未决的信号到信号集set中。即未决信号集,未决信号集不仅包括被阻塞的信号,也可能包括已经到达但没有被处理的信号。

示例1: sigaction函数的用法

void signal_set(struct sigaction *act)
{
switch(act->sa_flags){
    case (int)SIG_DFL:
        printf("using default hander/n");
        break;
    case (int)SIG_IGN:
        printf("ignore the signal/n");
        break;
    default:
        printf("%0x/n",act->sa_handler);
    }
}
void signal_set1(int x){//信号处理函数
    printf("xxxxx/n");
    while(1){}
}

int main(int argc,char** argv)
{
    int i;
    struct sigaction act,oldact;
    act.sa_handler = signal_set1;
    act.sa_flags = SA_RESETHAND;
    //SA_RESETHANDD 在处理完信号之后,将信号恢复成默认处理
    //SA_NODEFER在信号处理程序执行期间仍然可以接收信号
    sigaction (SIGINT,&act,&oldact) ;//改变信号的处理模式
    for (i=1; i<12; i++)
    {
        printf("signal %d handler is : ",i);
        sigaction (i,NULL,&oldact) ;
        signal_set(&oldact);//如果act为NULL,oldact会存储信号当前的行为
        //act不为空,oldact不为空,则oldact会存储信号以前的处理模式
    }
    while(1){
        //等待信号的到来
    }
    return 0;
}

运行结果:

[root@localhost C]# ./s2
signal 1 handler is : using default hander
signal 2 handler is : 8048437
signal 3 handler is : using default hander
signal 4 handler is : using default hander
signal 5 handler is : using default hander
signal 6 handler is : using default hander
signal 7 handler is : using default hander
signal 8 handler is : using default hander
signal 9 handler is : using default hander
signal 10 handler is : using default hander
signal 11 handler is : using default hander
xxxxx

解释:

sigaction(i,NULL,&oldact);
signal_set(&oldact);

由于act为NULL,那么oldact保存的是当前信号的行为,当前的第二个信号的行为是执行自定义的处理程序。
当按下CTRL+C时会执行信号处理程序,输出xxxxxx,再按一下CTRL+C会停止,是由于SA_RESETHAND恢复成默认的处理模式,即终止程序。
如果没有设置SA_NODEFER,那么在处理函数执行过程中按一下CTRL+C将会被阻塞,那么程序会停在那里。

示例2: sigqueue向本进程发送数据的信号

int main(){
    union sigval val;//定义一个携带数据的共用体
    struct sigaction oldact,act;
    act.sa_sigaction=myhandler;
    act.sa_flags=SA_SIGINFO;//表示使用sa_sigaction指示的函数,处理完恢复默认,不阻塞处理过程中到达下在被处理的信号
    //注册信号处理函数
    sigaction(SIGUSR1,&act,&oldact);
    char data[100];
    int num=0;
    while(num<10){
        sleep(2);
        printf("等待SIGUSR1信号的到来/n");
        sprintf(data,"%d",num++);
        val.sival_ptr=data;
        sigqueue(getpid(),SIGUSR1,val);//向本进程发送一个信号
    }
}

void myhandler(int signo,siginfo_t *si,void *ucontext){
    printf("已经收到SIGUSR1信号/n");
    printf("%s/n",(char*)(si->si_ptr));
}

程序执行的结果是:

等待SIGUSR1信号的到来
已经收到SIGUSR1信号
0
等待SIGUSR1信号的到来
已经收到SIGUSR1信号
1
等待SIGUSR1信号的到来
已经收到SIGUSR1信号
2
等待SIGUSR1信号的到来
已经收到SIGUSR1信号
3
等待SIGUSR1信号的到来
已经收到SIGUSR1信号
4
等待SIGUSR1信号的到来
已经收到SIGUSR1信号
5
等待SIGUSR1信号的到来
已经收到SIGUSR1信号
6
等待SIGUSR1信号的到来
已经收到SIGUSR1信号
7
等待SIGUSR1信号的到来
已经收到SIGUSR1信号
8
等待SIGUSR1信号的到来
已经收到SIGUSR1信号
9

解释: 本程序用sigqueue不停的向自身发送信号,并且携带数据,数据被放到处理函数的第二个参数siginfo_t结构体中的si_ptr指针,当num<10时不再发。

一般而言,sigqueue与sigaction配合使用,而kill与signal配合使用。

示例3: 一个进程向另外一个进程发送信号,并携带信息

发送端:

int main(){
    union sigval value;
    value.sival_int=10;
    
    if(sigqueue(4403,SIGUSR1,value)==-1){//4403是目标进程pid
        perror("信号发送失败/n");
    }
    sleep(2);
}

接收端:

int main(){
    struct sigaction oldact,act;
    act.sa_sigaction=myhandler;
    act.sa_flags=SA_SIGINFO|SA_NODEFER;
    //表示执行后恢复,用sa_sigaction指示的处理函数,在执行期间仍然可以接收信号
    sigaction(SIGUSR1,&act,&oldact);
    while(1){
        sleep(2);
        printf("等待信号的到来/n");
    }
}

void myhandler(int signo,siginfo_t *si,void *ucontext){
    printf("the value is %d/n",si->si_int);
}

示例4: sigpending的用法

sigpending(sigset_t *set)将未决信号放到指定的set信号集中去,未决信号包括被阻塞的信号和信号到达时但还没来得及处理的信号

int main(){
    struct sigaction oldact,act;
    sigset_t oldmask,newmask,pendingmask;
    act.sa_sigaction=myhandler;
    act.sa_flags=SA_SIGINFO;
    sigemptyset(&act.sa_mask);//首先将阻塞集合设置为空,即不阻塞任何信号
    //注册信号处理函数
    sigaction(SIGRTMIN+10,&act,&oldact);
    //开始阻塞
    sigemptyset(&newmask);
    sigaddset(&newmask,SIGRTMIN+10);
    printf("SIGRTMIN+10 blocked/n");
    sigprocmask(SIG_BLOCK,&newmask,&oldmask);
    sleep(20);//为了发出信号
    printf("now begin to get pending mask/n");
    if(sigpending(&pendingmask)<0){
        perror("pendingmask error");
    }
    if(sigismember(&pendingmask,SIGRTMIN+10)){
        printf("SIGRTMIN+10 is in the pending mask/n");
    }
    
    sigprocmask(SIG_UNBLOCK,&newmask,&oldmask);
    printf("SIGRTMIN+10 unblocked/n");
}
//信号处理函数
void myhandler(int signo,siginfo_t *si,void *ucontext){
    printf("receive signal %d/n",si->si_signo);
}

程序执行,在另一个shell发送信号:

 kill -44 4579

SIGRTMIN+10 blocked
now begin to get pending mask
SIGRTMIN+10 is in the pending mask
receive signal 44
SIGRTMIN+10 unblocked

可以看到SIGRTMIN由于被阻塞所以处于未决信号集中。
关于基本的信号处理函数就介绍到这了。

第四部分: 保护临界区不被中断

1. 函数的可重入性

函数的可重入性是指可以多于一个任务并发使用函数,而不必担心数据错误。相反,不可重入性是指不能多于一个任务共享函数,除非能保持函数互斥(或者使用信号量,或者在代码的关键部分禁用中断)。可重入函数可以在任意时刻被中断,稍后继续执行,而不会丢失数据。

可重入函数:

  • 不为连续的调用持有静态数据。
  • 不返回指向静态数据的指针;所有数据都由函数的调用者提供。
  • 使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据。
  • 绝不调用任何不可重入函数。

不可重入函数可能导致混乱现象,如果当前进程的操作与信号处理程序同时对一个文件进行写操作或者是调用malloc(),那么就可能出现混乱,当从信号处理程序返回时,造成了状态不一致。从而引发错误。
因此,信号的处理必须是可重入函数。
简单的说,可重入函数是指在一个程序中调用了此函数,在信号处理程序中又调用了此函数,但仍然能够得到正确的结果。
printf,malloc函数都是不可重入函数。printf函数如果打印缓冲区一半时,又有一个printf函数,那么此时会造成混乱。而malloc函数使用了系统全局内存分配表。

2. 保护临界区不被中断

由于临界区的代码是关键代码,是非常重要的部分,因此,有必要对临界区进行保护,不希望信号来中断临界区操作。这里通过信号屏蔽字来阻塞信号的发生。

下面介绍两个与保护临界区不被信号中断的相关函数。

int pause(void);
int sigsuspend(const sigset_t *sigmask);

pause函数挂起一个进程,直到一个信号发生。

sigsuspend函数的执行过程如下:
(1)设置新的mask去阻塞当前进程
(2)收到信号,调用信号的处理函数
(3)将mask设置为原先的掩码
(4)sigsuspend函数返回

可以看出,sigsuspend函数是等待一个信号发生,当等待的信号发生时,执行完信号处理函数后就会返回。它是一个原子操作。

保护临界区的中断:
(1)首先用sigprocmask去阻塞信号
(2)执行后关键代码后,用sigsuspend去捕获信号
(3)然后sigprocmask去除阻塞
这样信号就不会丢失了,而且不会中断临界区。

上面的程序是用pause去保护临界区,首先用sigprocmask去阻塞SIGINT信号,执行临界区代码,然后解除阻塞。最后调用pause()函数等待信号的发生。但此时会产生一个问题,如果信号在解除阻塞与pause之间发生的话,信号就可能丢失。这将是一个不可靠的信号机制。
因此,采用sigsuspend可以避免上述情况发生。

sigsuspend函数的用法:
sigsuspend函数是等待的信号发生时才会返回。
sigsuspend函数遇到结束时不会返回,这一点很重要。

示例:

下面的例子能够处理信号SIGUSR1,SIGUSR2,SIGSEGV,其它的信号被屏蔽,该程序输出对应的信号,然后继续等待其它信号的出现。

void myhandler(int signo);
int main(){
    struct sigaction action;
    sigset_t sigmask;
    sigemptyset(&sigmask);
    sigaddset(&sigmask,SIGUSR1);
    sigaddset(&sigmask,SIGUSR2);
    sigaddset(&sigmask,SIGSEGV);
    action.sa_handler=myhandler;
    action.sa_mask=sigmask;
    sigaction(SIGUSR1,&action,NULL);
    sigaction(SIGUSR2,&action,NULL);
    sigaction(SIGSEGV,&action,NULL);
    sigfillset(&sigmask);
    sigdelset(&sigmask,SIGUSR1);
    sigdelset(&sigmask,SIGUSR2);
    sigdelset(&sigmask,SIGSEGV);
    while(1){
        sigsuspend(&sigmask);//不断的等待信号到来
    }
    return 0;
}
    
void myhandler(int signo){
    switch(signo){
        case SIGUSR1:
            printf("received sigusr1 signal./n");
        break ;
        case SIGUSR2:
            printf("received sigusr2 signal./n");
        break;
        case SIGSEGV:
            printf("received sigsegv signal/n");
        break;
    }
}

程序运行结果:

received sigusr1 signal
received sigusr2 signal
received sigsegv signal
received sigusr1 signal
已终止

另一个终端用于发送信号:
先得到当前进程的pid, ps aux|grep 程序名

kill -SIGUSR1 4901
kill -SIGUSR2 4901
kill -SIGSEGV 4901
kill -SIGTERM 4901
kill -SIGUSR1  4901

解释:
第一行发送SIGUSR1,则调用信号处理函数,打印出结果。
第二,第三行分别打印对应的结果。
第四行发送一个默认处理为终止进程的信号。
但此时,但不会终止程序,由于sigsuspend遇到终止进程信号并不会返回,此时并不会打印出”已终止”,这个信号被阻塞了。当再次发送SIGURS1信号时,进程的信号阻塞恢复成默认的值,因此,此时将会解除阻塞SIGTERM信号,所以进程被终止。

第五部分: 信号的继承与执行

当使用fork()函数时,子进程会继承父进程完全相同的信号语义,这也是有道理的,因为父子进程共享一个地址空间,所以父进程的信号处理程序也存在于子进程中。

示例: 子进程继承父进程的信号处理函数

void myhandler(int signo,siginfo_t *si,void *vcontext);
int main(){
    union sigval val;
    struct sigaction oldact,newact;
    newact.sa_sigaction=myhandler;
    newact.sa_flags=SA_SIGINFO|SA_RESETHAND;//表示采用sa_sigaction指示的函数以及执行完处理函数后恢复默认操作
    //注册信号处理函数
    sigaction(SIGUSR1,&newact,&oldact);
    
    if(fork()==0){
        val.sival_int=10;
        printf("子进程/n");
        sigqueue(getpid(),SIGUSR1,val);
    }
    else {
        val.sival_int=20;
        printf("父进程/n");
        sigqueue(getpid(),SIGUSR1,val);
    }
}

void myhandler(int signo,siginfo_t *si,void *vcontext){
    printf("信号处理/n");
    printf("%d/n",(si->si_int));
}

输出的结果为:

子进程
信号处理
10
父进程
信号处理
20

可以看出来,子进程继承了父进程的信号处理函数。

第六部分: 实时信号中锁的研究

1. 信号处理函数与主函数之间的死锁

当主函数访问临界资源时,通常需要加锁,如果主函数在访问临界区时,给临界资源上锁,此时发生了一个信号,那么转入信号处理函数,如果此时信号处理函数也对临界资源进行访问,那么信号处理函数也会加锁,由于主程序持有锁,信号处理程序等待主程序释放锁。又因为信号处理函数已经抢占了主函数,因此,主函数在信号处理函数结束之前不能运行。因此,必然造成死锁。

示例1: 主函数与信号处理函数之间的死锁

int value=0;
sem_t sem_lock;//定义信号量
void myhandler(int signo,siginfo_t *si,void *vcontext);//进程处理函数声明
int main(){
    union sigval val;
    val.sival_int=1;
    struct sigaction oldact,newact;
    int res;
    res=sem_init(&sem_lock,0,1);
    if(res!=0){
        perror("信号量初始化失败");
    }
    
    newact.sa_sigaction=myhandler;
    newact.sa_flags=SA_SIGINFO;
    sigaction(SIGUSR1,&newact,&oldact);
    sem_wait(&sem_lock);
    printf("xxxx/n");
    value=1;
    sleep(10);
    sigqueue(getpid(),SIGUSR1,val);//sigqueue发送带参数的信号
    sem_post(&sem_lock);
    sleep(10);
    exit(0);
}

void myhandler(int signo,siginfo_t *si,void *vcontext){
    sem_wait(&sem_lock);
    value=0;
    sem_post(&sem_lock);
}

此程序将一直阻塞在信号处理函数的sem_wait函数处。

2. 利用测试锁解决死锁

sem_trywait(&sem_lock);是非阻塞的sem_wait,如果加锁失败或者是超时,则返回-1。
示例2: 用sem_trywait来解决死锁

int value=0;
sem_t sem_lock;//定义信号量
void myhandler(int signo,siginfo_t *si,void *vcontext);//进程处理函数声明
int main(){
    union sigval val;
    val.sival_int=1;
    struct sigaction oldact,newact;
    int res;
    res=sem_init(&sem_lock,0,1);
    if(res!=0){
        perror("信号量初始化失败");
    }
    
    newact.sa_sigaction=myhandler;
    newact.sa_flags=SA_SIGINFO;
    sigaction(SIGUSR1,&newact,&oldact);
    sem_wait(&sem_lock);
    printf("xxxx/n");
    value=1;
    sleep(10);
    sigqueue(getpid(),SIGUSR1,val);//sigqueue发送带参数的信号
    sem_post(&sem_lock);
    sleep(10);
    sigqueue(getpid(),SIGUSR1,val);
    exit(0);
}

void myhandler(int signo,siginfo_t *si,void *vcontext){
    if(sem_trywait(&sem_lock)==0){
        value=0;
        sem_post(&sem_lock);
    }
}

第一次发送sigqueue时,由于主函数持有锁,因此,sem_trywait返回-1,当第二次发送sigqueue时,主函数已经释放锁,此时就可以在信号处理函数中对临界资源加锁了。
但这种方法明显丢失了一个信号,不是很好的解决方法。

3. 利用双线程来解决主函数与信号处理函数死锁

我们知道,当进程收到一个信号时,会选择其中的某个线程进行处理,前提是这个线程没有屏蔽此信号。因此,可以在主线程中屏蔽信号,另选一个线程去处理这个信号。由于主线程与另外一个线程是平行执行的,因此,等待主线程执行完临界区时,释放锁,这个线程去执行信号处理函数,直到执行完毕释放临界资源。

这里用到一个线程的信号处理函数: pthread_sigmask

int pthread_sigmask(int how,const sigset_t *set,sigset_t *oldset);

这个函数与sigprocmask很相似。
how的取值:
SIG_BLOCK 将信号集加入到线程的阻塞集中去
SIG_UNBLOCK 将信号集从阻塞集中删除
SIG_SETMASK 将当前集合设置为线程的阻塞集

示例: 利用双线程来解决主函数与信号处理函数之间的死锁

void*thread_function(void *arg);//线程处理函数
void myhandler(int signo,siginfo_t *si,void *vcontext);//信号处理函数
int value;
sem_t semlock;
int main(){
    int res;
    pthread_t mythread;
    void *thread_result;
    res=pthread_create(&mythread,NULL,thread_function,NULL);//创建一个子线程
    if(res!=0){
        perror("线程创建失败");
    }

    //在主线程中将信号屏蔽
    sigset_t empty;
    sigemptyset(&empty);
    sigaddset(&empty,SIGUSR1);
    pthread_sigmask(SIG_BLOCK,&empty,NULL);

    //主线程中对临界资源的访问
    if(sem_init(&semlock,0,1)!=0){
        perror("信号量创建失败");
    }
    sem_wait(&semlock);
    printf("主线程已经执行/n");
    value=1;
    sleep(10);
    sem_post(&semlock);
    res=pthread_join(mythread,&thread_result);//等待子线程退出
    exit(EXIT_SUCCESS);
}

void *thread_function(void *arg){
    struct sigaction oldact,newact;
    newact.sa_sigaction=myhandler;
    newact.sa_flags=SA_SIGINFO;
    //注册信号处理函数
    sigaction(SIGUSR1,&newact,&oldact);
    union sigval val;
    val.sival_int=1;
    printf("子线程睡眠3秒/n");
    sleep(3);
    sigqueue(getpid(),SIGUSR1,val);
    pthread_exit(0);//线程结束
}

void myhandler(int signo,siginfo_t *si,void *vcontext){
    sem_wait(&semlock);
    value=0;
    printf("信号处理完毕/n");
    sem_post(&semlock);
}

运行结果如下:

主线程已经执行
子线程睡眠3秒
信号处理完毕

解释一下:
在主线线程中阻塞了SIGUSR1信号,首先让子线程睡眠3秒,目的让主线程先运行,然后当主线程访问临界资源时,让线程sleep(10),在这期间,子线程发送信号,此时子线程会去处理信号,而主线程依旧平行的运行,子线程被阻止信号处理函数的sem_wait处,等待主线程10后,信号处理函数得到锁,然后进行临界资源的访问。这就解决了主函数与信号处理函数之间的死锁问题。

扩展: 如果有多个信号到达时,还可以用多线程来处理多个信号,从而达到并行的目的,这个很好实现的,可以尝试一下。

1.资源与内存分配

资源的概念:资源是数量有限且对系统正常运转具有一定作用的元素。比如,内存,文件句柄,网络套接字(network sockets),互斥锁(mutex locks)等等
对于进程,这些资源都作为某种数据结构存储在内存中。
程序运行需要分配内存来管理以上资源,内存分配可以分为三类:

  • 静态分配:如创建一个进程执行某段代码,需要加载该代码的代码段,数据段等数据到内存中,其中数据段包含已初始化的全局数据,可以称为是静态的内存分配
  • 自动分配:进程内函数的调用和返回,以及其内部的局部变量创建和销毁,对应该进程高地址的入栈出栈,这个是操作系统自动处理的,无需应用程序控制
  • 动态分配:静态数据和堆栈之前的空间(称为堆),可由应用程序动态分配,同时,也必须由应用程序释放。所谓的内存的动态分配与释放,通常讨论的是这种情况

以32位Linux环境的应用程序为例,每个进程可见的(虚拟)内存分布如下,C/C++常用的malloc/free, new/delete对应的内存分配释放都在.heap段内
image-20221208165846274

2.动态内存管理的缺陷

我们在使用资源时必须严格遵循的步骤是:

  1. 获取资源
  2. 使用资源
  3. 释放资源

代码形式:

void UseResources()    
{  
    // 获取资源1  
    // ...  
    // 获取资源n  
     
    // 使用这些资源  
     
    // 释放资源n  
    // ...  
    // 释放资源1  
} 

当代码量和复杂度达到一定程度,这种手动资源管理容易出错,且难以避免
例如C++使用new和delete时可能发生的一些错误是:

  • 内存泄漏:例如,使用new分配对象,而忘记删除该对象,打开文件,忘记关闭文件等等
  • 过早删除(或悬挂引用):持有指向对象的另一个指针,删除该对象,但是还有其他指针在引用它。
  • 双重删除:尝试两次删除一个对象

3.RAII:将资源管理交给系统

  • 自动内存管理,局部变量能在调用函数时分配,退出函数时释放
  • 类是 C++ 中的主要抽象工具,将资源抽象为类,用局部对象来表示资源,把管理资源的任务转化为管理局部对象的任务

RAII 就是基于以上思想,折中了全手动和全自动的内存管理,手动的选择管理哪些资源,自动的分配和释放资源。有效地实现了 C++ 资源管理的自动化

RAII(Resource Acquisition Is Initialization, 资源获取即初始化):
是80年代,Bjarne Stroustrup为C++发明了的范例。
具体实现方法:将资源的声明周期,绑定到对象的生命周期,即将资源分配和释放操作,包含到指定对象的构造函数和析构函数中,这些构造函数和析构函数在适当的时候由编译器自动调用,资源数据包含到对象的成员中。

一个简单示例:

(1)常规内存管理

#include <iostream> 
using namespace std; 
int main() 
{ 
    int *testArray = new int [10]; 
    // Here, you can use the array 
    delete [] testArray; 
    testArray = NULL ; 
    return 0; 
}

(2)RAII方式

#include <iostream> 
using namespace std; 

class ArrayOperation 
{ 
public : 
    ArrayOperation() 
    { 
        m_Array = new int [10]; //构造函数包含资源的分配
    } 
 
    void InitArray()  //使用资源
    { 
        for (int i = 0; i < 10; ++i) 
        { 
            *(m_Array + i) = i; 
        } 
    } 
 
    void ShowArray() //使用资源
    { 
        for (int i = 0; i <10; ++i) 
        { 
            cout<<m_Array[i]<<endl; 
        } 
    } 
 
    ~ArrayOperation()  //析构函数包含资源的释放
    { 
        cout<< "~ArrayOperation is called" <<endl; 
        if (m_Array != NULL ) 
        { 
            delete[] m_Array;  
            m_Array = NULL ; 
        } 
    } 
 
private : 
    int *m_Array;  //成员变量包含资源
}; 
 
int main() 
{ 
    ArrayOperation arrayOp; //资源自动分配
    arrayOp.InitArray(); 
    arrayOp.ShowArray(); 
    return 0;           //资源自动释放
}

根据RAII对资源的所有权控制,分为常性类型和外部初始化类型
上述示例即为常性类型,也是最纯粹的RAII形式,最容易理解,最容易编码。获取资源的地点是构造函数,释放点是析构函数,并且在这两点之间的一段时间里,任何对该RAII类型实例的操纵都不应该从它手里夺走资源的所有权
外部初始化类型是指资源在外部被创建,并被传给RAII实例的构造函数,后者进而接管了其所有权。boost::shared_ptr<>和std::auto_ptr<>都是此类型

4.RAII的应用场景

常见的应用有:

  • 文件操作
  • 智能指针
  • 互斥量

4.1文件操作

(1)常规形式

void UseFile(char const* fn)  
{  
    FILE* f = fopen(fn, "r");        // 获取资源  
    // 在此处使用文件句柄f...代码          // 使用资源  
    fclose(f);                       // 释放资源  
}  

(2)RAII
文件类:

class FileHandle {  
public:  
    FileHandle(char const* n, char const* a) { p = fopen(n, a); } 
    ~FileHandle() { fclose(p); }  
private:   
    FileHandle(FileHandle const&);  
    FileHandle& operator= (FileHandle const&); // 禁止拷贝操作  
    FILE *p;  
}; 

FileHandle 类的构造函数调用 fopen() 获取资源;FileHandle类的析构函数调用 fclose()释放资源。请注意,考虑到FileHandle对象代表一种资源,它并不具有拷贝语义,因此将拷贝构造函数和赋值运算符声明为私有成员
使用:

void UseFile(char const* fn)  
{  
    FileHandle file(fn, "r");  
    // 在此处使用文件句柄  
    // 超出此作用域时,系统会自动调用file的析构函数,从而释放资源
}  

4.2互斥量

C++标准库提供lock_guard类实现mutex分配与释放,其实现就是RAII方式。

template<class... _Mutexes>
    class lock_guard
    {    // class with destructor that unlocks mutexes
public:
    explicit lock_guard(_Mutexes&... _Mtxes)
        : _MyMutexes(_Mtxes...)
        {    // construct and lock
        _STD lock(_Mtxes...);
        }
 
    lock_guard(_Mutexes&... _Mtxes, adopt_lock_t)
        : _MyMutexes(_Mtxes...)
        {    // construct but don't lock
        }
 
    ~lock_guard() _NOEXCEPT
        {    // unlock all
        _For_each_tuple_element(
            _MyMutexes,
            [](auto& _Mutex) _NOEXCEPT { _Mutex.unlock(); });
        }
 
    lock_guard(const lock_guard&) = delete;
    lock_guard& operator=(const lock_guard&) = delete;
private:
    tuple<_Mutexes&...> _MyMutexes;
    };

使用多线程时,经常会涉及到共享数据的问题,C++中通过实例化std::mutex创建互斥量,通过调用成员函数lock()进行上锁,unlock()进行解锁。不过这意味着必须记住在每个函数出口都要去调用unlock(),也包括异常的情况,这非常麻烦,而且不易管理。C++标准库为互斥量提供了一个RAII语法的模板类std::lock_guard,其会在构造函数的时候提供已锁的互斥量,并在析构的时候进行解锁,从而保证了一个已锁的互斥量总是会被正确的解锁。上面的代码属于mutex头文件

4.3智能指针

先看一个例子,用RAII管理指针

#include <iostream>
#include <mutex>
#include <fstream>
using namespace std;

enum class shape_type {
    circle,
    rectangle,
};

class shape {
public:
    shape() { cout << "shape" << endl; }
    virtual void print() {
        cout << "I am shape" << endl;
    }
    virtual ~shape() {}
};

class circle : public shape {
public:
    circle() { cout << "circle" << endl; }
    void print() {
        cout << "I am circle" << endl;
    }
};

class rectangle : public shape {
public:
    rectangle() { cout << "rectangle" << endl; }
    void print() {
        cout << "I am rectangle" << endl;
    }
};

// 利用多态上转,如果返回值为shape,会存在对象切片问题。
shape *create_shape(shape_type type) {
    switch (type) {
        case shape_type::circle:
            return new circle();
        case shape_type::rectangle:
            return new rectangle();
    }
}

class shape_wrapper {
public:
    explicit shape_wrapper(shape *ptr = nullptr) : ptr_(ptr) {}

    ~shape_wrapper() {
        delete ptr_;
    }

    shape *get() const {
        return ptr_;
    }

private:
    shape *ptr_;
};



int main() {

    // 第一种方式, 手动管理指针
    shape *sp = create_shape(shape_type::circle);
    sp->print();
    delete sp; //显式delete

    // 第二种方式, RAII管理指针,一般封装到函数,更快释放
    shape_wrapper ptr(create_shape(shape_type::circle));
    ptr.get()->print();

    return 0;
}

C++标准库的智能指针:auto_ptr(C++11弃用), unique_ptr,shared_ptr, weak_ptr
可以参考WindSun:详解C++11智能指针

4.4实现自己的RAII类

一般情况下,RAII临时对象不允许复制和赋值,当然更不允许在heap上创建,所以先写下一个RAII的base类,使子类私有继承Base类来禁用这些操作:

class RAIIBase  
{  
public:  
    RAIIBase(){}  
    ~RAIIBase(){}//由于不能使用该类的指针,定义虚函数是完全没有必要的  
      
    RAIIBase (const RAIIBase &);  
    RAIIBase & operator = (const RAIIBase &);  
    void * operator new(size_t size);   
    // 不定义任何成员  
};

要写自己的RAII类时就可以直接继承该类的实现

template<typename T>  
class ResourceHandle: private RAIIBase //私有继承 禁用Base的所有继承操作  
{  
public:  
    explicit ResourceHandle(T * aResource):r_(aResource){}//获取资源  
    ~ResourceHandle() {delete r_;} //释放资源  
    T *get()    {return r_ ;} //访问资源  
private:  
    T * r_;  
};

将Handle类做成模板类,这样就可以将class类型放入其中。另外,ResourceHandle可以根据不同资源类型的释放形式来定义不同的析构函数。由于不能使用该类的指针,所以不使用虚函数。

5.GC和RAII

在没有RAII的时代,GC和非GC语言是水火不容,GC追求开发效率和稳健设计,非GC如C++最求极致性能和绝对控制。RAII的设计机制,兼顾了两者的优点。
如果用三个等级代表程序员对系统资源的使用权限,如下:

  • 动态分配:C++的new/delete之类,程序员100%负责内存使用和释放,编译器、操作系统不额外干预
  • 垃圾回收(GC):java/go语言之类,程序员只负责要内存,而不用管,也管不了内存释放,其由该语言运行环境管理,规则可以描述成:如果一个资源没被任何对象使用(例如没有指针指向它),运行环境定时或者其他方式检测到后,自动释放该资源,该过程对程序员不可控。可以说程序员有50%的权限,即想要就能要,但想甩却不能甩
  • RAII:程序员负责资源编排,运行时的分配与释放由系统自动完成,可以说程序员有90%的权限,放权10%给系统

小结

RAII的本质内容是用对象代表资源,把管理资源的任务转化为管理对象的任务,将资源的获取和释放与对象的构造和析构对应起来,从而确保在对象的生存期内资源始终有效,对象销毁时资源必被释放。换句话说,拥有对象就等于拥有资源,对象存在则资源必定存在。
具体实现:

  • 资源在构造函数中获取
  • 资源在析构函数中释放
  • 资源是类的成员变量
  • 类的实例是堆栈分配的

相关文章
C++那些事:RAII

套接字(socket)基础

套接字是网络编程中,应用层和传输层之间的数据结构,其作用如下:
应用层通过传输层进行数据通信时,TCP和UDP会遇到同时为多个应用程序进程提供并发服务的问题。多个TCP连接或多个应用程序进程可能需要通过同一个TCP协议端口传输数据。为了区别不同的应用程序进程和连接,许多计算机操作系统为应用程序与TCP/IP协议交互提供了称为套接字(Socket)的接口,以区分不同应用程序进程间的网络通信和连接。

套接字地址结构

通用套接字地址的结构体sockaddr定义如下:
1
在以太网中,不直接使用sockaddr结构体,而使用sockaddr_in,其定义如下:

3

通用结构体sockaddr和以太网的sockaddr_in结构体,有点像C++中的父类和子类的关系,sockaddr_in是对sockaddr的细化,其存储结构大小相同,分布如下:
4
由于大小相同,在设置socket地址时,一般先设置sockaddr_in结构体,然后强转为sockaddr类型

套接字地址结构在用户层和内核层的交互

sockaddr的使用,以socket流程中的bind()函数为例:
5
bind函数需要传入sockaddr结构体的指针,和sockaddr结构体的长度

向内核传入数据

向内核传入数据的socket函数有:bind,send
传入过程如下:

  • sockaddr结构体的长度,以传值方式传入内核
  • 内核通过sockaddr结构体的指针以及结构体长度,以内存复制的方式,从用户层拷贝sockaddr结构体到内核层。

6

从内核获取数据

从内核得到数据的socket函数有:accept,recv

  • sockaddr结构体的长度,以传值方式传入内核
  • 内核通过sockaddr结构体的指针以及结构体长度,以内存复制的方式,从内核层拷贝sockaddr结构体到用户层。
  • 内核返回内核的结构体的长度
    7

    Socket编程流程

    总体架构

    TCP编程主要为C/S模式,即服务器和客户端编程,我们讲socket编程,要区分是服务器端的流程还是客户端的流程。
  • 服务器端:创建服务-等待客户端连接-收到连接请求-处理
  • 客户端:发起对服务器的连接请求-根据服务器的响应做处理

服务端各函数含义:

  • socket:套接字初始化
  • bind:绑定套接字和端口
  • listen:配置服务器的请求队列,监测连接请求
  • accept:接受客户端连接
  • read/write:数据的接收、发送
  • close:断开连接,释放套接字

客户端函数:

  • 客户端套接字不需要绑定端口和监听,直接connect发起连接请求,其他函数和服务端一致。

socket函数

socket函数用于创建socket套接字的文件描述符,

9

有三个入参:

  • domain:域,区分本地,IPV4 Internet,IPV6 Internet等。有的以PF开头,有的以AF开头,这两者值一样。

10

  • type:通信类型,如流式(TCP),数据报式(UDP)等

11

  • protocal:协议类型,指定通信类型中的子类型,一般为0

socket套接字初始化的一个例子:
12

socket函数在应用层和内核层的交互

用户调用的socket函数,会调用内核的sys_socket函数

2

sys_socket做两件事:

  • sock_create生成内核的socket结构,和应用层的结构不同,如下:

    13

  • sock_map_fd将内核socket结构绑定文件描述符fd,用户层可通过fd访问内核socket结构

bind函数

服务端用socket函数建立套接字文件描述符后,需要绑定地址和端口到该文件描述符,才能接受客户端请求。

14

  • sockfd:socket函数创建的文件描述符
  • sockaddr结构的指针:指向的sockaddr结构,包含ip和port等信息
  • addrlen:即sizeof(struct sockaddr)

bind函数绑定UNIX族的套接字:

15

bind函数绑定AF_INET族的套接字:

16

bind函数在应用层和内核层的交互

以AF_INET族的套接字绑定为例,不同协议族实际上是调用内核不同的绑定函数
image-20221205141814380

listen函数

listen函数用于初始化服务器的可连接队列,即服务器处理客户的请求,不是并行处理,而是异步的串行处理。服务器建立可连接队列,将当前不能同步处理的新请求放到队列中,等队列前面的请求处理完了,才异步处理这个请求。
18

  • backlog是服务器可连接队列的最大长度
  • 当前队列没满,即当前队列的请求没超过backlog值,才可以调用accept
  • listen函数只针对SOCK_STREAM和SOCK_SEQPACKET才能调用,因为TCP请求才需要建立连接。对于SOCK_DGRAM不支持listen,因为UDP是无连接的。

TCP连接中,SOCK_STREAM类型的套接字,调用listen的示例:
image-20221205141912644
image-20221205141921877

listen函数在应用层和内核层的交互

image-20221205141934130

accept函数

服务端用listen建立连接队列后,客户端以connect发来一个请求,会加入到服务端连接队列的队尾,当这个请求到达队头,会调用accept真正处理该请求。
accept会创建一个新的套接字文件描述符,用来描述客户端的连接,这个时候会有两个套接字描述符并存:

  • socket函数创建的老的sockfd,表示正在监听的ip和端口
  • accept函数创建的新的clientfd,表示当前的客户端连接,后续的客户端的收发和客户端关闭,即send,recv,close函数,都使用clientfd

image-20221205142030402

流式连接的accept示例:
image-20221205142038423
image-20221205142048069

accept函数在应用层和内核层的交互

image-20221205142100841

connect函数

connect函数是客户端调用的函数,在客户端调用socket函数创建套接字文件sockfd后,可调用connect函数向服务端发起连接请求,请求的ip和端口信息包含在sockaddr结构体内。
image-20221205142256082

客户端的socket connect示例:

connect函数在应用层和内核层的交互

根据数据流式或数据报式的请求,具体调用inet_stream_connect或inet_dgram_connect
image-20221205142315046

read和write函数

服务端和客户端真正建立连接后(socket通信逻辑连接,不是TCP/UDP的面向连接/无连接的传输层连接),即客户端发起connect,服务端accept完成,双方就可以相互read/write,读写对方的数据,通过sockfd文件描述符,就像读写本地文件一样。

  • read:从套接字文件读取数据,写入本地缓冲区,返回非空的有效数据大小
    image-20221205142328017
  • write:向套接字文件写入数据,将本地缓冲区数据写入socket函数创建的socket文件

close和shutdown函数

  • close:关闭socket连接,释放内核的套接字资源,不能通过套接字文件来读写操作
  • shutdown:支持读写的单向关闭,即关闭套接字文件的读、写、或者读写能力(等同于close)

Socket客户端和服务端交互的例程

整体架构

客户端从标准输入读取用户输入的字符串,发送给服务端,服务端读取数据,回写这些数据到客户端,客户端收到数据后输出到标准输出。
image-20221205142345794

客户端和服务端可以在同一台机器部署,访问回环地址127.0.0.1即可,注意服务端的监听端口不能和其他进程的端口冲突。

代码实现

服务端代码:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <unistd.h>
//#define PORT 8088                        /*侦听端口地址*/
#define BACKLOG 2                        /*侦听队列长度*/

int main(int argc, char *argv[])
{
    int ss,sc;        /*ss为服务器的socket描述符,sc为客户端的socket描述符*/
    struct sockaddr_in server_addr;    /*服务器地址结构*/
    struct sockaddr_in client_addr;    /*客户端地址结构*/
    int err;                            /*返回值*/
    pid_t pid;                            /*分叉的进行ID*/

    /*建立一个流式套接字*/
    ss = socket(AF_INET, SOCK_STREAM, 0);
    if(ss < 0){                            /*出错*/
        printf("socket error\n");
        return -1;    
    }
    
    /*设置服务器地址*/
    bzero(&server_addr, sizeof(server_addr));            /*清零*/
    server_addr.sin_family = AF_INET;                    /*协议族*/
    server_addr.sin_addr.s_addr = htonl(INADDR_ANY);    /*本地地址*/
    //server_addr.sin_port = htons(PORT);
    server_addr.sin_port = htons(atoi(argv[1]));        /*服务器端口*/
    
    /*绑定地址结构到套接字描述符*/
    err = bind(ss, (struct sockaddr*)&server_addr, sizeof(server_addr));
    if(err < 0){/*出错*/
        printf("bind error\n");
        return -1;    
    }
    
    /*设置侦听*/
    err = listen(ss, BACKLOG);
    if(err < 0){                                        /*出错*/
        printf("listen error\n");
        return -1;    
    }
    
        /*主循环过程*/
    for(;;)    {
        socklen_t addrlen = sizeof(struct sockaddr);
        /*接受客户端连接*/
        sc = accept(ss, (struct sockaddr*)&client_addr, &addrlen); 
        if(sc < 0){                            /*出错*/
            continue;                        /*结束本次循环*/
        }    
        
        /*建立一个新的进程处理到来的连接*/
        pid = fork();                        /*分叉进程*/
        if( pid == 0 ){                        /*子进程中*/
            process_conn_server(sc);        /*处理连接*/
            close(ss);                        /*在子进程中关闭服务器的侦听*/
        }else{
            close(sc);                        /*在父进程中关闭客户端的连接*/
        }
    }
}

服务端注意几点:

  • accept后处理连接的过程,是在子进程中处理的,使用fork创建子进程用于连接处理。根据fork返回的pid是0还是其他,判断当前调度到子进程还是父进程,从全局上来讲,这个if-else的两种流程分别在父进程和子进程中指向。
  • 服务端有两个套接字:侦听套接字和连接套接字。处理连接传入的是连接套接字。
  • 在父进程(侦听进程)中,要关闭连接套接字;在子进程(连接处理进程)中,要关闭侦听套接字。这是为了避免子父进程相互影响。
  • 对于多进程,一个进程的套接字关闭不会释放该套接字内存,只有所有进程都关闭了这个套接字,内核才会 释放该套接字,所有可以放心在侦听进程和连接处理进程中,关闭对方的套接字。

客户端代码:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <unistd.h>
#include <arpa/inet.h>
//#define PORT 8088                                /*侦听端口地址*/

int main(int argc, char *argv[])
{
    int s;                                        /*s为socket描述符*/
    struct sockaddr_in server_addr;            /*服务器地址结构*/
    
    s = socket(AF_INET, SOCK_STREAM, 0);         /*建立一个流式套接字 */
    if(s < 0){                                    /*出错*/
        printf("socket error\n");
        return -1;
    }    
    
    /*设置服务器地址*/
    bzero(&server_addr, sizeof(server_addr));    /*清零*/
    server_addr.sin_family = AF_INET;                    /*协议族*/
    server_addr.sin_addr.s_addr = htonl(INADDR_ANY);    /*本地地址*/
    server_addr.sin_port = htons(atoi(argv[2]));        /*服务器端口*/
    
    /*将用户输入的字符串类型的IP地址转为整型*/
    inet_pton(AF_INET, argv[1], &server_addr.sin_addr);    
    /*连接服务器*/
    connect(s, (struct sockaddr*)&server_addr, sizeof(struct sockaddr));
    process_conn_client(s);                        /*客户端处理过程*/
    close(s);                                    /*关闭连接*/
    return 0;
}

建立连接后的读写交互代码,包含服务端的调用和客户端的调用:

#include <stdio.h>
#include <string.h>
/*客户端的处理过程*/
void process_conn_client(int s)                    /* 传入的是客户端调用socket时创建的s */
{
    ssize_t size = 0;
    char buffer[1024];                            /*数据的缓冲区*/
    
    for(;;){                                    /*循环处理过程*/
        /*从标准输入中读取数据放到缓冲区buffer中,标准输入:0,标准输出:1,标准错误:2*/
        size = read(0, buffer, 1024);
        if(size > 0){                            /*读到数据*/
            write(s, buffer, size);                /*发送给服务器*/
            /*客户端阻塞,等待服务器有数据可读*/
            size = read(s, buffer, 1024);        /*从服务器读取数据*/
            write(1, buffer, size);                /*写到标准输出*/
        }
    }    
}
/*服务器对客户端的处理*/
void process_conn_server(int s)                 /* 传入的是服务端调用accept时创建的sc */
{
    ssize_t size = 0;
    char buffer[1024];                            /*数据的缓冲区*/
    
    for(;;){                                    /*循环处理过程*/        
        size = read(s, buffer, 1024);            /*从套接字中读取数据放到缓冲区buffer中*/
        if(size == 0){                            /*没有数据*/
            return;    
        }
        
        /*构建响应数据*/
        //sprintf(buffer, "server receive %d bytes from client\n", size);
        //write(s, buffer, strlen(buffer));
        write(s, buffer, size);                    /*发回给客户端*/
    }    
}

Makefile编译脚本:

all:client server                    #all规则,它依赖于client和server规则

client:tcp_process.o tcp_client.o    #client规则,生成客户端可执行程序
    gcc -o client tcp_process.o tcp_client.o
server:tcp_process.o tcp_server.o    #server规则,生成服务器端可执行程序
    gcc -o server tcp_process.o tcp_server.o    
tcp_process.o:                        #tcp_process.o规则,生成tcp_process.o
    gcc -c tcp_process.c -o tcp_process.o
clean:                                #清理规则,删除client、server和中间文件
    rm -f client server *.o

部署和运行

后台运行server,指定监听端口:
image-20221205142401747
运行client,指定服务端的ip, port:

客户端每输入一个字符串,服务端返回完全相同的字符串,通信正常
如果运行服务端时,有bind error,可能是端口被占用,netstat找到占用端口的PID,kill之后再运行server
image-20221205142416741