C++和Go相关

C++

链接

image-20210904195125524

静态链接

静态链接
静态链接器以一组可重定位目标文件为输入,生成一个完全链接的可执行目标文件作为输出。链接器主要完成以下两
个任务:
符号解析:每个符号对应于一个函数、一个全局变量或一个静态变量,符号解析的目的是将每个符号引用与一
个符号定义关联起来。
重定位:链接器通过把每个符号定义与一个内存位置关联起来,然后修改所有对这些符号的引用,使得它们指
向这个内存位置。

image-20210904195229372

目标文件
可执行目标文件:可以直接在内存中执行;
可重定位目标文件:可与其它可重定位目标文件在链接阶段合并,创建一个可执行目标文件;
共享目标文件:这是一种特殊的可重定位目标文件,可以在运行时被动态加载进内存并链接;

动态链接
静态库有以下两个问题:
当静态库更新时那么整个程序都要重新进行链接;
对于 printf 这种标准函数库,如果每个程序都要有代码,这会极大浪费资源。
共享库是为了解决静态库的这两个问题而设计的,在 Linux 系统中通常用 .so 后缀来表示,Windows 系统上它们被
称为 DLL。它具有以下特点:
在给定的文件系统中一个库只有一个文件,所有引用该库的可执行目标文件都共享这个文件,它不会被复制到
引用它的可执行文件中;
在内存中,一个共享库的 .text 节(已编译程序的机器代码)的一个副本可以被不同的正在运行的进程共享。

image-20210904195259492

进程和线程

对于Linux的进程,进程是一个数据结构task_struct,它就是Linux内核检测到的一个描述。其中有一个mm指针,它指向的是进程的虚拟内存,files指针指向一个数据, files_struct 结构来记录文件描述符的使用情况。一般来说,一个进程会从files[0]读取输入,将输出写入到files[1],将错误信息写入files[2]。每个进程被创建时候files0-2分别被填入标准输入流、标准输出流、标准错误流。如果我们写的程序需要其他资源,比如打开一个文件进行读写,就进行一个系统调用,让内核把文件打开,放到files[4]
从Linux内核的角度,线程和进程并没有区别对待,都是用task_struct来表示,唯一的区别就是共享的数据区域不同。对于同一个进程的线程来说,他们的mm和files都指向同一块区域。

线程共享的环境包括:进程代码段、进程的公有数据(利用这些共享的数据,线程很容易的实现相互之间的通讯)、进程打开的文件描述符、信号的处理器、进程的当前目录和进程用户ID与进程组ID。

C++的内存分区

.reserve(预留)段
一共占用128M,属于预留空间,进程是禁止访问的

.text(代码段)
可执行文件加载到内存中的只有数据和指令之分,而指令被存放在.text段中,一般是共享的,编译时确定,只读,不允许修改

.data
存放在编译阶段(而非运行时)就能确定的数据,可读可写。也就是通常所说的静态存储区,赋了初值的全局变量和赋初值的静态变量存放在这个区域,常量也存放在这个区域

.bss段
通常用来存放程序中未初始化以及初始化为0的全局/静态变量的一块内存区域,在程序载入时由内核清0

.heap(堆)
用于存放进程运行时动态分配的内存,可动态扩张或缩减,这块内存由程序员自己管理,通过malloc/new可以申请内存,free/delete用来释放内存,heap的地址从低向高扩展,是不连续的空间

.stack(栈)
记录函数调用过程相关的维护性信息,栈的地址从高地址向低地址扩展,是连续的内存区域

共享库(libc.so)
静态库和动态库的区别:
(1)、不同操作系统下后缀不一样

windows    linux

静态库 .lib .a
动态/共享库 .dll .so
1
2
3
(2)、加载方法的时间点不同
*.a 在程序生成链接的时候已经包含(拷贝)进来了
*.so 程序在运行的时候才加载使用

(3)静态库把包含调用函数的库是一次性全部加载进去的,动态库是在运行的时候,把用到的函数的定义加载进去,所以包含静态库的程序所以用静态库编译的文件比较大,如果静态库改变了,程序得重新编译,相反的,动态库编译的可执行文件较小,但.so改变了,不影响程序,动态库的开发很方便

(4)程序对静态库没有依赖性,对动态库有依赖性。

img

image-20210905231656813

大端和小端

大端模式:低位字节存在高地址上,高位字节存在低地址上
小端模式:高位字节存在高地址上,低位字节存在地地址上
地址从左往右增长,字节则是左高右低。0x2211,大端存储是0x2211,小端是0x1122

函数调用的过程

函数调用过程中用到了一种很重要的数据结构——栈帧。其本质也是一种栈,这种栈专门用于保存函数
调用过程中的各种信息(参数、返回地址、本地变量等)。栈帧有栈顶和栈底之分,其中栈顶的地址最
低,栈底的地址最高,
两个关键的寄存器:
esp:栈指针寄存器(extended stack pointer),其内存放着一个指针,该指针永远指向系统栈最上
面一个栈帧的栈顶。
ebp:基址指针寄存器(extended base pointer),其内存放着一个指针,该指针永远指向系统栈最
上面一个栈帧的底部。

image-20210904200137742

一般来说,ebp到esp之间的区域就是栈帧。在操作系统的栈空间中可以有多个栈帧,每调用一个函数,
就会生出一个新的栈帧。在调用过程中,我们将调用函数的函数称为“调用者”,被调用的函数称为“被调
用者”。调用者需要知道在哪获取被调用者的返回值,调用之需要知道传入的参数在哪、返回的地址在
哪。同时我们也要保住两个指针在调用前后一致。

函数调用的过程:

调用者做了两件事:1、将被调用函数的参数按照从右到左的顺序压入栈中;2、将返回地址压入栈中。
被调用者也做了两件事:1、将调用者的ebp压入栈,作为保存;2、将esp的值赋予ebp。此时ebp就有
了新值,指向存放老的ebp的栈空间。此时这个位置变成了被调用函数的栈帧的栈底,并建立一个新的
栈帧。
在ebp被赋值后,就可以在新的栈帧上开辟空间,存放被调用函数的本地变量。如图:

image-20210904200209665

image-20210904200218310

函数的返回
函数返回的过程和调用过程相反,当被调用函数结束后,它会将esp转移到ebp处,并且弹出ebp中存储
的值,再赋值给ebp,这样,ebp就回到了调用前的状态。

socket

socket函数

1
int socket(int family,int type,int protocol);

socket函数用于创建socket,它指定了期望的通信协议类型,其中family参数指明协议族(一般使
用AF_INET指定IPV4, AF_INET6指定IPV6),type参数指明套接字类型(一般使用SOCK_STREAM
指定TCP,SOCK_DGRAM指定UDP),protocol参数一般设为0.
socket函数执行成功时返回一个非负整数值,称为监听套接字描述符,简称为sockfd
bind函数

1
int bind(int sockfd,const struct sockaddr *myaddr,socklen_t addrlen);

bind函数将一个socket与socket地址进行了绑定。bind将my_addr所指向的socket地址分配给
sockfd,addrlen指出该socket地址的长度、

listen函数

1
int listen(int sockfd, int backlog);

socket被绑定后,还不能马上接收客户端的连接。listen函数把一个未连接的套接字转换成一个被
动套接字,指示内核应该接受指向该套接字的连接请求。第二个参数backlog,代表未完成连接队
列和已完成队列连接之和的最大值。
accept函数

1
int accept(int sockfd,struct sockaddr *cliaddr, socklen_t *addrlen);

accept函数从已完成连接的队列接受队列头的已完成连接。参数cliaddr用来返回已连接的客户端
的协议地址。
如果accept成功,那么返回值是一个全新的fd,称为已连接套接字。它和监听套接字有所区别。一
个服务器通常只创建一个监听套接字,它在该服务器的生命周期内一直存在,内核会为每个由服务
器进程接收的客户端连接创建一个已连接套接字。当完成客户端和服务器的交流后,这个已连接套
接字就关闭。
close函数

1
int close(int sockfd)

close一个TCP的默认行为是把该套接字标记为已关闭,然后返回到调用的进程。该套接字不能再
使用。

客户端:
socket函数
connect函数

1
int connect(int sockfd, const struct sockaddr *servaddr, socklen_t addrlen);

sockfd是socket函数返回的套接字描述符,servaddr是一个指向套接字地址结构的指针,套接字
地址结构必须含有服务器的IP地址和端口号。
connect函数会导致客户端从CLOSED状态转移到SYN_SENT状态。若connect函数成功返回会再次
转移到ESTABILISHED状态

三次握手

们知道tcp建立连接要进行“三次握手”,即交换三个分组。大致流程如下:

  • 客户端向服务器发送一个SYN J
  • 服务器向客户端响应一个SYN K,并对SYN J进行确认ACK J+1
  • 客户端再想服务器发一个确认ACK K+1

只有就完了三次握手,但是这个三次握手发生在socket的那几个函数中呢?请看下图:

image

图1、socket中发送的TCP三次握手

从图中可以看出,当客户端调用connect时,触发了连接请求,向服务器发送了SYN J包,这时connect进入阻塞状态;服务器监听到连接请求,即收到SYN J包,调用accept函数接收请求向客户端发送SYN K ,ACK J+1,这时accept进入阻塞状态;客户端收到服务器的SYN K ,ACK J+1之后,这时connect返回,并对SYN K进行确认;服务器收到ACK K+1时,accept返回,至此三次握手完毕,连接建立。

总结:客户端的connect在三次握手的第二个次返回,而服务器端的accept在三次握手的第三次返回。

四次挥手

上面介绍了socket中TCP的三次握手建立过程,及其涉及的socket函数。现在我们介绍socket中的四次握手释放连接的过程,请看下图:

image

图2、socket中发送的TCP四次握手

图示过程如下:

  • 某个应用进程首先调用close主动关闭连接,这时TCP发送一个FIN M;
  • 另一端接收到FIN M之后,执行被动关闭,对这个FIN进行确认。它的接收也作为文件结束符传递给应用进程,因为FIN的接收意味着应用进程在相应的连接上再也接收不到额外数据;
  • 一段时间之后,接收到文件结束符的应用进程调用close关闭它的socket。这导致它的TCP也发送一个FIN N;
  • 接收到这个FIN的源发送端TCP对它进行确认。

这样每个方向上都有一个FIN和ACK。

UDP:

服务器:
socket函数
bind函数
客户端:
socket函数

Go

GMP

GMP是Go语言自己实现的一套调度系统。

1.Go就是goroutinue,里面除了存放goroutinue的信息,还有与P的绑定信息

2.P管理这一组goroutinue队列,P会存储当前goruntinue运行的上下文信息(函数指针,堆栈指针以及地址边界).P会对自己管理的gorountine队列做一些调度(把占用CPU时间长的goruntine暂停、运行后续的goruntinue等)。等自己的队列消费完就去全局队列会去其他的P里抢任务。

3.M是Go对操作系统内核线程的虚拟,M与内核线程、P都是一一对应的关系。

P管理着一组G挂载在一个M上执行,当G长久阻塞在M上,会新建一个M供其执行。当旧的G阻塞完成或者认为其已经死掉,回收旧的M。

垃圾回收

GC 不回收什么?

为了解释垃圾回收是什么,我们先来说说 GC 不回收什么。在我们程序中会使用到两种内存,分别为堆(Heap)和栈(Stack),而 GC 不负责回收栈中的内存。那么这是为什么呢?

主要原因是栈是一块专用内存,专门为了函数执行而准备的,存储着函数中的局部变量以及调用栈。除此以外,栈中的数据都有一个特点——简单。比如局部变量就不能被函数外访问,所以这块内存用完就可以直接释放。正是因为这个特点,栈中的数据可以通过简单的编译器指令自动清理,也就不需要通过 GC 来回收了。

为什么需要垃圾回收?

现在我们知道了垃圾回收只负责回收堆中的数据,那么为什么堆中的数据需要自动垃圾回收呢?

其实早期的语言是没有自动垃圾回收的。比如在 C 语言中就需要使用 malloc/free 来人为地申请或者释放堆内存。这种做法除了增加工作量以外,还容易出现其他问题[1]。

一种可能是并发问题,并发执行的程序容易错误地释放掉还在使用的内存。一种可能是重复释放内存,还有可能是直接忘记释放内存,从而导致内存泄露等问题。而这类问题不管是发现还是排查往往会花费很多时间和精力。所以现代的语言都有了这样的需求——一个自动内存管理工具。

什么是垃圾回收?

看到这里,垃圾回收的定义也就十分清楚了。当我们说垃圾回收(GC garbage collection)的时候,我们其实说的是自动垃圾回收(Automatic Garbage Collection),一个自动回收堆内存的工具。所以垃圾回收一点也不神奇,它只是一种工具,可以更便捷更高效地帮助程序员管理内存。

2.三色标记法

追踪式垃圾回收(Tracing garbage collection)

主流的两类垃圾回收算法有两种,分别是追踪式垃圾回收算法[1]和引用计数法( Reference counting )。而三色标记法是属于追踪式垃圾回收算法的一种。

为什么需要三色标记法?

在三色标记法之前有一个算法叫 Mark-And-Sweep(标记清扫),这个算法就是严格按照追踪式算法的思路来实现的。这个算法会设置一个标志位来记录对象是否被使用。最开始所有的标记位都是 0,如果发现对象是可达的就会置为 1,一步步下去就会呈现一个类似树状的结果。等标记的步骤完成后,会将未被标记的对象统一清理,再次把所有的标记位设置成 0 方便下次清理。

这个算法最大的问题是 GC 执行期间需要把整个程序完全暂停,不能异步进行 GC 操作。因为在不同阶段标记清扫法的标志位 0 和 1 有不同的含义,那么新增的对象无论标记为什么都有可能意外删除这个对象。对实时性要求高的系统来说,这种需要长时间挂起的标记清扫法是不可接受的。所以就需要一个算法来解决 GC 运行时程序长时间挂起的问题,那就三色标记法。

三色标记法好在哪里?

相比传统的标记清扫算法,三色标记最大的好处是可以异步执行,从而可以以中断时间极少的代价或者完全没有中断来进行整个 GC。

三色标记法过程。

  • 首先将对象用三种颜色表示,分别是白色、灰色和黑色。
  • 最开始所有对象都是白色的,然后把其中全局变量和函数栈里的对象置为灰色。
  • 第二步把灰色的对象全部置为黑色,然后把原先灰色对象指向的变量都置为灰色,
  • 以此类推。等发现没有对象可以被置为灰色时,所有的白色变量就一定是需要被清理的垃圾了。

三色标记法因为多了一个白色的状态来存放不确定的对象,所以可以异步地执行。当然异步执行的代价是可能会造成一些遗漏,因为那些早先被标记为黑色的对象可能目前已经是不可达的了。所以三色标记法是一个 false negative(假阴性)的算法。

3.一次完整回收过程

1)Go 执行三色标记前,需要先做一个准备工作——打开 Write Barrier。

Write Barrier

那么 Write Barrier[1]是什么呢?我们知道三色标记法是一种可以并发执行的算法。所以在运行过程中程序的函数栈内可能会有新分配的对象,那么这些对象该怎么通知到 GC,怎么给他们着色呢?这个时候就需要我们的 Write Barrier 出马了。Write Barrier 主要做这样一件事情,修改原先的写逻辑,然后在对象新增的同时给它着色,并且着色为”灰色“。因此打开了 Write Barrier 可以保证了三色标记法在并发下安全正确地运行。

Stop The World

不过在打开 Write Barrier 前有一个依赖,我们需要先停止所有的 goroutine,也就是所说的 STW(Stop The World)操作。那么接下来问题来了,GC 该怎么通知所有的 goroutine 停止呢 ?

我们知道,在停止 goroutine 的方案中,Go 语言采取的是合作式抢占模式(当前 1.13 及之前版本)。这种模式的做法是在程序编译阶段注入额外的代码,更精确的说法是在每个函数的序言中增加一个合作式抢占点。因为一个 goroutine 中通常有无数调用函数的操作,选择在函数序言中增加抢占点可以较好地平衡性能和实时性之间的利弊。在通常情况下,一次 Mark Setup 操作会在 10-30 微秒[3]之间。

2)Marking 标记(Concurrent)

在第一阶段打开 Write Barrier 后,就进入第二阶段的标记了。Marking 使用的算法就是我们之前提到的三色标记法,这里不再赘述。不过我们可以简单了解一下标记阶段的资源分配情况。

在标记开始的时候,收集器会默认抢占 25% 的 CPU 性能,剩下的75%会分配给程序执行。但是一旦收集器认为来不及进行标记任务了,就会改变这个 25% 的性能分配。这个时候收集器会抢占程序额外的 CPU,这部分被抢占 goroutine 有个名字叫 Mark Assist。而且因为抢占 CPU的目的主要是 GC 来不及标记新增的内存,那么抢占正在分配内存的 goroutine 效果会更加好,所以分配内存速度越快的 goroutine 就会被抢占越多的资源。

除此以外 GC 还有一个额外的优化,一旦某次 GC 中用到了 Mark Assist,下次 GC 就会提前开始,目的是尽量减少 Mark Assist 的使用,从而避免影响正常的程序执行。

3)Mark Termination 标记结束(STW)

最重要的 Marking 阶段结束后就会进入 Mark Termination 阶段。这个阶段会关闭掉已经打开了的 Write Barrier,和 Mark Setup 阶段一样这个阶段也需要 STW。

标记结束阶段还需要做的事情是计算下一次清理的目标和计划,比如第二阶段使用了 Mark Assist 就会促使下次 GC 提早进行。如果想人为地减少或者增加 GC 的频率,那么我们可以用 GOGC 这个环境变量设置。一个小细节是在 Go 的文档[5]中有提及, Go 的 GC 有且只会有一个参数进行调优,也就是我们所说的 GOGC,目的是为了防止大家在一大堆调优参数中摸不着头脑。

通常情况下,标记结束阶段会耗时 60-90 微秒。

4)weeping 清理(Concurrent)

最后一个阶段就是垃圾清理阶段,这个过程是并发进行的。清扫的开销会增加到分配堆内存的过程中,所以这个时间也是无感知不会与垃圾回收的延迟相关联。

5)总结

一次完整的垃圾回收会分为四个阶段,分别是标记准备、标记、结束标记以及清理。在标记准备和标记结束阶段会需要 STW,标记阶段会减少程序的性能,而清理阶段是不会对程序有影响的。目前已经讲了这么多理论了,所以在下一篇文章中,我们会介绍一些实战案例。

内存分配