Skip to content

Latest commit

 

History

History
617 lines (326 loc) · 44.8 KB

读书笔记.md

File metadata and controls

617 lines (326 loc) · 44.8 KB

一、深入浅出go语言

第四章、复杂数据类型

4.3、map

  1. 声明,使用var

  2. make创建

  3. 遍历,顺序是随机的

  4. 元素的查找,由于直接索引map['key'],若不存在则返回对应值类型的零值,因此会有歧义,即返回的零值不知道是由于不存在键值对还是存在但是键值对的值为零值的歧义。因此采用value,exist:=map[key]的方式消除了查找的歧义。

  5. 删除:delete(map,key)

  6. 存储结构解析:hamp结构;bmap数据结构

4.4、channel

许多观点认为channel是消息队列或维为了线程同步而创建的数据结构。

  1. 创建,需要make关键字,并指定存储的数据类型,第二个参数表明缓冲区大小,若不指定,则代表无缓冲区。

    msgChannel := make(chan int, 100)

    channel读写数据的特点决定了缓冲区的意义。channel可以看作一个消息通道,通道的主要作用就是保证数据通过,因此,除了向其中写入数据外,还需要从其中读取数据。在没有缓冲区的情况下,当读操作就绪时才能写入数据。这如同一个没有仓库的货站,必须有等待接收货物的车辆,送货车辆才能顺畅送货;否则,送货车辆只能停在货站等待。而缓冲区就如同货站的仓库,即使没有接收车辆,送货车辆也可以向货站送货,因为可以临时将货物放入仓库中。

  2. 读写,都使用左箭头<-,channel的位置决定是读操作还是写操作。对空chan读和对满chan写都会阻塞。输出的数据可以直接到打印台如fmt.Println(<-msgChannel)

  3. 实现原理

    1. channel的数据结构,hchan,包括元素数量,缓冲区大小,缓冲区指针(指向循环队列),元素大小,关闭状态,元素数据类型,已被读取的数据位置,当前已经写入的数据位置,等待写入和等待读取的阻塞协程队列。

    2. 写入的过程

    3. 读取的过程

  4. 与消息队列的对比:消息队列可以回溯,但channel消费后就被移除(覆盖么?);消息队列中能被多个消费者消费,channel中则不行;channel是进程级缓存,只能用于协程通信,而消息队列可以用于进程间;重点不一样。。。。

4.5、自定义结构体

  1. 自定义数据类型

第六章、函数

6.1函数在go中的地位

  1. 函数和方法的区别:方法的接收者;

6.2、函数的定义

  1. 函数参数:值类型和指针类型

  2. func name(args_name type) (return_name type){body}

  3. 返回值:位于声明的最后,且可以有多个返回值(两种返回方式,一种是在函数体内显示声明,显示return返回,第二种是在函数声明处显式声明,在函数体内赋值,并隐式return)。多返回值的原理可以理解为底层编译时将返回值视作变量,并直接进行操作。

6.3、模块和包

  1. 使用go mod init name创建模块。模块下会有一个mod文件,该文件是整个模块共用的配置文件,其作用是管理整个模块的外部依赖包。

  2. 本地包管理:跨包代码引用,文件夹路径与包名称并无硬性关系。

  3. 模块名和文件夹名称的关系:.go文件内声明的包名称才是引入时用的,引入时并不看文件夹名称,保持一致是一个好习惯。

  4. 当两个文件中定义的包名相同,可以定义包的别名别名 “addres to package”。·

  5. go中的常量初始化、变量初始化、名为init的函数会被隐式的自动执行,而main是程序的入口。

  6. 对外部依赖越少的,执行优先级越高,先加载被依赖的包,因为常量肯定不依赖于变量,所以常量优先于变量,init会在常量和变量之后执行。main最后执行。

  7. 匿名包实现函数导入:因为若不显示使用引入的包,则会报错。所以为了解决这种,代码内不需显式调用,但是需要该包的init函数在后台运行是,就使用_作为匿名包调用,此时不显示调用包内代码,调用了初始化函数,但不会报错

6.4、将函数当作变量使用

  1. 将函数赋值给变量:即函数可以像普通数值一样赋给变量,暂存函数成为可能,函数的声明被抽象为数据类型层面

  2. 可以通过变量调用函数,Function_Variable_Name()

  3. 应用场景:使用map可以将字符串与函数做映射

  4. 包可以有别名

6.6、匿名函数和闭包

  1. 为什么需要匿名函数:当函数只在特定位置调用而不需要复用,就可以定义匿名函数(与cpp中的lambda当作回调函数有什么关系?)

  2. 匿名函数定义:func() {Function Body}。可以当作函数参数传入声明同样签名函数变量为参数的函数。

  3. 当函数之间进行调用时,往往会被处理为多个栈帧的创建和释放,但go语言在编译匿名函数时会采用内联编译,即将调用与被调用编译在一起,平铺式的复制到调用处(类似于cpp的内联函数?),实际执行时会减小栈帧,对性能有一定提升

6.6.2、闭包

func getAnonymousFunc() func(){
    i:=0
    f:=func(){
    //局部变量自增
    i++
    //打印局部变量
    fmt.Println(i)
    }
    return f
}
  1. 改代码定义了一个函数即getAnonymousFunc(闭包函数),该函数用于生成一个函数对象,可以从返回值func()看出。

  2. 该闭包函数定义了一个局部变量i,且i定义于匿名函数之外

  3. 匿名函数修改了i的值,一般说来,当外部的闭包函数执行结束,内部的局部变量会被释放,也就是i会被销毁,但是连着两次调用闭包函数,i不仅存在,还进行了递增,这是为什么呢?因为局部变量会随着所处函数栈帧的销毁而销毁。

  4. 闭包的意义:该闭包函数中的局部变量i会被分配在堆空间而不是栈空间,即使外部闭包函数执行完毕,对应的栈内存销毁,i也不会销毁,且由于匿名函数一直保持对i的引用,变量i也不会被GC回收。无论是从getAnonymousFunc()的定义形式,还是其最终内存分配策略,或者代码的执行效果,都可以看出,getAnonymousFunc()生成的匿名函数就像一个封闭的、独立的小王国,具有一直驻留在内存的“环境变量”(我们姑且将闭包看作一个独立的运行环境),同时具有自己的执行逻辑。如同“闭包”这个字眼所表达的那样。

6.7函数的强制类型转换

6.7.1从数据类型的定义到函数类型的定义:在不考虑具体实现的情况下,函数要素可以抽象为参数+返回值的组合,这种定义不涉及具体的实现,就如同uint8和uint32不考虑具体数值,只限定数据长度一样

6.7.2从数据类型的强制转换到函数类型的强制转换:同数值的强制类型转换,不对变量进行类型声明,由编译器进行类型推导,赋值阶段会出现隐式的类型强制转换

6.7.3函数类型强制转换的意义:在go中数据类型可以作为参数、返回值、函数的接收者(receiver,从而为数据类型绑定方法)等,这意味着,我们不仅可以像传递普通变量一样传递函数对象,还可以为函数绑定方法,而绑定后的方法可以被函数对象调用,相当于凭空为函数扩展了功能,如以下代码所示。。通过强制转化,可以让多个与F1结构相同的函数拥有F1所绑定的方法,即此时F1成为了提供公共能力的载体。

6.7.4利用强制转换为函数绑定方法:即创建一个与多个待绑定函数【Target_Functions】类型相同的函数类型Inter_Funcion,为该类型绑定一个test函数,此时将每一个Target_Functions都强制转换为Inter_Function,此时每个新对象都拥有了test方法。

6.8、闭包的使用

传统的理解是:封装了变量和函数的独立环境

  1. 闭包无法修改包外部的变量。当将闭包外的变量以函数传参的方式传值给闭包函数,函数会复制该变量的值,并在该包中修改,但是这个修改是不会影响到外部的原变量的。即闭包里面的函数,只能修改闭包里面的变量。

  2. 可以以参数的形式传参给闭包函数,此时闭包函数内的函数的操作就可以影响到外部的变量。

第八章、GO的面向对象

重点:

面向对象的本质

go实现封装

go实现继承

go实现多态

go中的面向接口编程

8.1、面向对象编程的本质

  1. 构建和设计项目时,首先考虑的是行动的主体--对象,抽象出对象,并为对象赋予相关的行为和字段,整个项目的运转通过对象之间的交互来实现,就像人类社会的分工一样

  2. 三大特性:封装、继承、多态

8.2、Go实现封装

封装是将属于某个对象的字段和行为抽象出来,形成一个有机体,在易于维护和契合事物特质的同时,可以用访问权限机制保证安全

  1. 使用结构体对字段和方法进行封装,结构体中本身可以包含字段,使用大小写实现访问权限控制。将某个函数的接收者指定为结构体,可以实现为结构体绑定方法。

  2. 为值类型绑定方法:将方法绑定到值类型上,则在方法内修改接收者的字段也不会影响原变量。func (worker Worker) do(){Function Body}

  3. 为指针类型绑定方法:更常用,可以修改原变量的字段,func (worker *Worker) do(){Function Body}

8.3、Go语言实现继承

在面向对象的编程中,继承的原意是代码复用,尽量避免重复。但是Go中并无继承机制,只能实现类似继承

  1. 利用组合集成其他对象的能力:当有结构体Human和Student时,若想要学生拥有Human的方法,则在Student结构体中声明一个Human字段即可,则此时Student实例就拥有了Human的方法,这种方式称为组合,即在一个结构体中声明另一个结构体类型的字段

    type Human struct{
        name string
    }
    func (human *Human) speak(){fmt.Pringln("my name is ", human.name}
    
    type Student struct{
        human Human
    }
    h := Human{name : "Mike"}
    student := Student{human:h}
    student.human.speak()
  2. 字段匿名化实现继承: Go允许字段的匿名化,因此省略student中的字段名称后,效果如下。则创建student时也可以省略字段名

    type Human struct{
        name string
    }
    func (human *Human) speak(){fmt.Pringln("my name is ", human.name}
    
    type Student struct{
         Human
    }
    h := Human{name : "Mike"}
    student := Student{h,} //省略字段名称human直接赋予Human结构体填充字段,因为类型匹配,所以合理
    student.speak()

    省略字段名后,利用Student创建实例时,会按字段顺序进行匹配,将h赋值给第一个字段Human;匿名化使得函数调用变味了student.speak()。本质上还是student调用匿名Human类字段的方法,形式上实现了继承。此时若使用fmt.Pringln("%v", student)能看到{Human:{name:Mike}},即匿名字段的实际名称为类型名字即Human。

  3. 匿名字段的支持:匿名字段如何注入结构体呢?即:对没有名称的字段,为实例填充字段时会按顺序匹配来进行填充,类型不匹配则会报错

  4. 多继承:同样,赋予同一个结构体多个结构体的匿名字段,则该结构体可以直接通过匿名调用的方式调用字段结构体的方法,实现类似多继承(但是填充的时候,要依次为子字段创建实例,有点麻烦,是否可以用:=来进行实时创建)

8.4、Go语言实现多态

在面向对象的编程中,多态有两种,类的多态和方法的多态。

类的多态通过继承实现,在Go中目前无法实现,因为继承是伪继承,后续通过接口实现

方法的多态是指在父类和子类中定义了某个方法,方法签名完全相同,使用时会根据对象的类型去定位实际执行的函数

  1. 利用方法重写无法实现方法多态:利用组合,重写方法并不会调用重写的函数,仍然调用原先的函数。原因是go中的继承本质是组合,而非真正的继承,无法在父类对象(子类对象的成员字段)中调用子类对象的函数,因为父类对象中没有指向子类对象的指针

  2. 正确的多态:将子类实例作为参数传入父类方法,根据不同类的子类实例,调用不同的方法,但是由于go中不存在继承关系,因此也就不能找到一个类来涵盖多种子类,仍然无法实现多态。因此要用面向接口的编程

8.5、面向接口编程

面向接口编程是指声明或定义对象时,将对象的类型定义为接口类型,而不使用具体类别,因为一个接口可以代表/涵盖多种具体类型。如此一来,当对象类型发生变更时,只要新类型实现了该接口,那么只需修改对象的赋值操作,后续代码操作均无需修改。这种编码方式,解耦了对象的赋值和操作两部分。

  1. Go语言中的接口:也是使用type关键字,可以理解为接口也是自定义类型,其中声明了若干方法但却不实现,方法的具体实现交给类来实现。接口类型的变量可以引用任何实现了这些方法的对象,Go语言中的接口类型是一个动态类型,他们可以引用任何类型的变量,即可以保存任何实现了该方法的值的引用。一个重要优点就是他解耦了对象的赋值和操作两个部分。由于接口是定义了对象的行为而不是具体实现,因此需要增添功能时,或支持新的类型时,只需定义新的接口或实现已有的接口,而不需要修改现有的代码,使得设计更加可扩展和模块化。

  2. 当结构体或数据类型实现了某个接口A的全部方法,那么就可以称该结构体和类型实现了A接口。如果某个接口中没有任何方法的声明,则为空接口,所有类型均可被视为空接口,也是Go语言中定义的特殊接口type any = interface{}。即any指任意数据类型

  3. 接口的实现:若函数签名相同,并实现了函数,则称之为实现了接口。若将拟实现多态的输入函数参数声明为相应的接口类型IF,在函数中调用接口的函数,而有A,B,C三类结构体实现了接口IF,则此时可以将A,B,C三类实例传入同一个函数,并在函数体中调用接口对象的函数,则可以根据不同的实例调用不同的A,B,C的方法,实现了根据传入参数的类型调用不同的函数,实现了多态。

  4. 接口实现多态:即,接口是作为接受不同类型对象的载体,且由于接口只声明方法而不实现方法,即该载体可以接受实现了函数签名相同的不同结构体,但各个结构体的具体实现又不同,从而实现多态。

  5. 接口的典型应用:1)接口嵌套,或接口组合, 以此实现接口复用(没看懂例子,多看几遍);2)伪继承和接口实现:go中的继承都是伪继承,即通过组合实现。即A实现了接口I,B中组合了结构体A,则B也视作实现了接口I。即接口与实现类的关联,可以视作是松散的契约关系,只要类在形式上拥有某接口的所有方法,即视作该类实现了该接口。

第九章、并发

重要内容:线程概念、三种线程模型、协程的工作原理、Go中的协程同步、利用channel实现协程同步、让出时间片、Go语言中的单例。

9.1、线程的概念

  1. 分为用户线程和内核线程。在用户态,每个线程可以理解为是一块内存地址,包含了线程执行所需的信息,如程序计数器、局部变量表、方法栈等,这块内存完全由线程私有,即不能访问其他线程的内存,也不能被访问;同时还有共享内存,即涉及线程安全问题;而有些操作必须在内核态执行,因此打通用户态和内核态的通道也是必备的。

  2. 多线程并发,即单个时间点只有一个线程在工作,但是宏观上看起来就像是多个线程一起同时执行一样,即并发,逻辑流上的重合。但是线程切换是有成本的,即cpu上下文切换,常见场景如:时间片轮转、主动让出、线程阻塞、被打断、硬件中断等。

9.2、线程模型

  1. 基于用户线程和内核线程的关系,提出了三种模型M:1、1:1、M:N三种。(用户:内核)

  2. 三种线程在线程切换和线程的生命周期两个方面各有优劣。

9.3、协程的工作原理

  1. 协程是M:N模型的一种体现

  2. 使用go指令即可启动一个协程,go会创建一个新的子协程来执行go中对应的后续指令,且为了让子协程获得执行机会,需要调用sleep让主协程休眠,否则主协程退出会导致子协程来不及运行结束。

  3. 可以使用go加匿名函数来执行不需要复用的多条指令(若需要复用则直接封装成函数)

  4. GPM模型:1)G:是goroutine的缩写,go关键字启动的协程会被封装为一个对象,作为具体操作指令的载体;2)P:是processor的缩写,代表协程处理器。Go中的多个协程会分配给一个内核线程,但是一个G不会直接与内核线程关联,而是进入一个由P维护的队列,并为了尽力避免内核线程的切换而进行调度,调度算法由go自身通过P实现;3)M:是machine的缩写,代表一个内核线程,是指令的实际执行者,一个M和一个P绑定在一起,M负责从P维护的G队列上依次获得G对象进行处理。

  5. 调度策略:1)G中遇到了I/O阻塞时;2)P中的G全部执行完毕;3)全局队列;4):自旋。即协程的调度是在编程语言层面实现的,而不是完全交给CPU进行处理。

  6. M:N调度复杂,风险高,部分场景用另外两种效果更好。M:N适用于大量的协程/线程并发(往往是异步任务),但是启动多少内核线程也是一个问题,通过GOMAXPROCS控制GPM中的P数量,默认情况下P的数量与cpu核心数相同,可以通过runtime.GOMAXPROCS()或者环境变量来设置。

9.4、Go中的协程同步

  1. Mutex:sys.Mutex{},Lock()和defer Unlock()

  2. RWMutex,是为了增加读数据并发量,同时保持共享数据稳定状态而提出的,读锁为共享锁,写锁为独占锁。共享锁是对于同一个信号量,可以被多个协程多次加锁,而独占锁则要求信号量对象没有被其他对象所锁定,无论是读锁还是写锁。lock := sync.RWMutex lock.RLock() lock.RUnlock lock.Lock() lock.Unlock()。读锁和写锁只是对传统的延续,而不是真的要读或者写才行。

  3. WaitGroup:通过设置计数器数值来让主协程阻塞,通过add来添加计数器数值,协程中的done函数可以让计数器减一,在计数器归零前主协程都阻塞在wait()函数处。

9.5、利用channel实现协程同步

  1. 实现互斥,可以将互斥看作是信号量机制中信号量为1的特殊情况,利用缓冲区为1的channel模拟互斥执行。

  2. 利用读写的阻塞,可以设置缓冲区为1或0,保证执行顺序,

  3. 利用channel实现等待组:在主协程中循环读取n个channel中的值,各协程执行完毕向channel写入,即可实现等待组

9.6、让出时间片

  1. time.Sleep()

  2. runtime.Gosched()

9.7、Go语言中的单例

单例也是并发控制中一个比较常见的应用场景。如果某个类负责创建自己在整个应用程序中的唯一实例,并提供访问该唯一实例的方法,则说明这个类实现了单例模式。在很多编程语言中,往往借助并发锁来完成单例对象的创建。

  1. 传统做法:两次判断的原因。

  2. 使用sync.Once实现单例

  3. onde的定义:结构体包含done整数字段,互斥体m,以及方法Do

  4. sync.Once.do()方法:原子判断done是否为零,若是,则调用私有方法(通过大小写判断)doSlow执行func

  5. sync.Once.doSlow():该函数保证了f只会被执行一次,获取独占锁,判断done变量并判断是否执行,最后设置done为1。

9.8、编程范例--协程池及协程中断

9.8.1、协程池实现

  1. 任务准备:定义任务接口,用来抽象多种任务的执行

  2. 协程池实现

  3. 协程池使用

第十章、上下文

二、C++服务器开发精髓

三、多线程编程与资源同步

3.1、线程的基本概念及常见问题

进程本身什么也不做,只提供上下文环境容器,实际是线程执行,即一个进程至少有一个线程,该线程为主线程

  1. 主线程退出,支线程:windows中,主线程推出会导致整个进程直接结束。linux中则工作线程不会退出,此时会变成僵尸进程,使用ps-ef查看,有defunc字段的即僵尸进程

  2. 某个线程崩溃,会导致进程退出么:一般来说,每个线程都是独立执行的单位,都有自己的上下文堆栈,一个线程崩溃不会对其他线程造成影响。但是在通常情况下,一个线程崩溃也会导致整个进程退出。例如在Linux操作系统中可能会产生一个Segment Fault错误,这个错误会产生一个信号,操作系统对这个信号的默认处理就是结束进程,这样整个进程都被销毁,在这个进程中存在的其他线程自然也就不存在了。

3.2、线程的基本操作

  1. 线程创建:pthread_create,线程函数的签名必须使用规定格式,即对参数个数、类型、返回个数都有要求

  2. cpp11的std::thread类:可以将任意函数当作线程函数。保证该线程类对象在线程函数期间是有效的,否则会崩溃。因此有detach函数,可以将线程对象与线程函数脱离关系,即使对象被销毁,线程函数也可以继续运行(原理是啥?)

  3. 获取线程ID:linux有三种方法:使用pthread_create时,传入的第一个参数就可以取到线程ID;pthread_t pthread_self(void);通过系统调用syscall;第一种和第二种结果一样,都是pthread_t类型,输出一块内存空间地址,即tid指向线程对象的空间结构,由于不同的进程可能有同样的地址的内存块,因此一二种方法的输出可能是一样的,第三种是系统范围内全局唯一的,也就是LWP的ID,即轻量级进程(是linux实现的线程,可以多了解一下)。

  4. cpp11获取线程ID:可以使用std::this_thread.get_id(),其返回值是一个std::thread::id的包装类型,不可以强制转换为整型,因此一般直接用cout输出,或转换为ostringstream对象,在转换为字符串,再把字符串转换为整形。

  5. 等待线程结束:linux:pthread_join用于等待线程结束退出并接受他的返回值,被称为汇接(join)。在等待期间会挂起当前线程,不会消耗cpu时间片,知道等待的线程退出,该线程才会被唤醒接着执行;cpp11:统一了linux和windows,使用std::thread.join()来等待,然而使用该方法必须保证,该线程处于运行状态,即cpp11中称之为可汇接的(joinable),若目标线程已退出,则会崩溃,因此也用joinable方法来判断是否可以join(那要是判断是否可以join的时候突然退出咋办)

3.3、惯用法:将c++类对象实例指针作为线程函数的参数

  1. 使用C++面向对象的方式对线程函数进行封装,若不用11的线程类,则函数只能是静态方法。若用11的线程类对象,则可以直接用实例函数当作线程函数,但必须显示的传递类对象实例指针,即this指针作为构造函数给线程类。

  2. 如果不使用C++11的语法,那么线程函数只能使用类的静态方法,且函数签名必须符合线程函数的签名要求。如果是类的静态方法,就无法访问类的实例方法了。为了解决这个问题,我们在实际开发中往往会在创建线程时将当前对象的地址(this指针)传递给线程函数,然后在线程函数中将该指针转换为原来的类实例,再通过这个实例就可以访问类的所有方法了。

  3. 使用时,thread接受一个可调用对象作为参数,可以是函数指针,lambda表达式,函数对象或是bind的返回值等,若是类成员函数则需要&取实例函数指针,而普通函数的名字就是指针,即地址,类成员函数还需传入this指针。

3.4、整型变量的原子操作

  1. 线程同步技术指的是,多个线程同时操作某个资源,我们需要采取一些措施保护资源,预防引起冲突,如死锁或得到一些意料之外的结果。

  2. 为什么给整型变量赋值不是原子操作:赋值,自增和自减操作,从汇编角度来看一般是三条指令,将一个变量的值赋给另一个变量,也是多个指令,非原子操作。

  3. cpp11 对整型变量原子操作的支持:atomic模板类,linux的g++编译器遵循了11的规范,即atomic的拷贝构造函数使用delete删除,无法进行拷贝:即不能使用std::atomic<int> value = 99;只能使用std::atomic<int> value; value = 99;

  4. 常见重载的操作:=,store,load,exchange。。。。。。

3.5、Linux线程同步对象

linux互斥体与条件变量一样,都在NPTL中实现,属于是操作系统层面提供的系统调用。主要内容为互斥体、信号量、条件变量,读写锁四个部分

  1. linux互斥体:使用宏静态初始化,使用函数动态初始化(需要设置属性,或者动态分配的对象,就用函数)。动态需要销毁,而静态的对象则不需要销毁,若正在被使用的互斥体对象被销毁,则会报错。

  2. pthread_mutex_t对象常用函数有pthread_mutex_lock(pthread_mutex_t *mutex)pthread_mutex_trylock(pthread_mutex_t *mutex)pthread_mutex_unlock(pthread_mutex_t *mutex)。通过pthread_mutexattr_settype(pthread_mutexattr_t *attr, int type)设置互斥体的类型,有三种PTHREAD_MUTEX_NORMAL\PTHREAD_MUTEX_ERRORCHECK\PTHREAD_MUTEX_RECURSIVE

  3. Linux信号量:信号量代表一定的资源数量,可以根据当前资源的数量按需唤醒指定数量的资源消费者线程,资源消费者线程一旦获取信号量,就会让资源减少指定的数量,如果资源数量减少为0,则消费者线程将全部处于挂起状态;当有新的资源到来时,消费者线程将继续被唤醒。另外,信号量有“资源有多份,可以同时被多个线程访问”的意思。

  4. Linux信号量常用API:init\destroy\post\wait\trywait\timedwait。编译时仍然需要链接pthread库。

  5. wait\trywait\timedwait在减一的同时会给信号量加锁,若为零则会阻塞在wait处,函数返回时会释放信号量的锁。post也会个信号量加锁,这是为了保证信号量的操作时原子的。

  6. wait\trywait\timedwait可以被linux信号中断,中断后,函数立即返回-1,错误码为errorno为EINTR。调用成功则返回0

  7. 条件变量:为何使用条件变量(多线程环境下的条件等待)、为什么要结合互斥体使用(保证等待和唤醒是在一个原子操作内的,因为信号之发送一次,错过就不再发送)、pthread_cond_wait对互斥锁的操作

  8. 条件变量的虚假唤醒与信号丢失:1)虚假唤醒,即操作系统可能在某些情况下唤醒条件变量,也就是说存在没有其他线程向条件变量发送信号,但等待此条件变量的线程有可能醒来的情形。我们将条件变量的这种行为称为虚假唤醒(spurious wakeup)。因此将条件(判断tasks.empty()为true)放在一个while循环中意味着光唤醒条件变量不行,还必须满足条件,程序才能继续执行正常的逻辑。2)虚假唤醒的原因:Linux中实实在在会出现这种问题,pthread_cond_wait属于阻塞性的系统调用,当系统调用被信号中断,会返回-1,并把错误码errorno设置为EINTR,因此很多这种系统调用被信号中断后都会采取重启的措施,即再次调用函数。但是如果wait函数被中断,重新调用pthread_cond_wait就会导致重启期间有可能错过信号,而信号是错过就不在产生的,因此宁可虚假唤醒,也不能再次调用函数;原因二是存在一些情况,在条件满足时发送信号,但等到调用pthread_cond_wait函数的线程得到CPU时间片时,条件又再次不满足了。因此,用while(条件){cv.wait;}来避免虚假唤醒带来的副作用

  9. 常用API:pthread_cond_init(pthread_cond_t * cond, const pthread_condattr_t* attr)pthread_cond_destroy(pthread_cond_t* cond)、或静态初始化pthread_cond_t cond = PTHREAD_COND_INITIALIZERpthread_cond_wait(pthread_cond_t *restrict cond, pthread_mutex_t* restrict mutex);或接口pthread_cond_timedwaie,而唤醒API为pthread_cond_signalpthread_cond_broadcast

  10. 读写锁默认是读锁优先。

3.7、cpp11/14/17线程同步对象

直接使用系统提供的api虽然限制少,但是对跨平台研发不友好,因此使用cpp11新标准的线程资源同步对象可以写出跨平台多线程程序

3.7.1、std::mutex系列:

  1. 互斥量类型:mutex、timed_mutex、recursive_mutex、recursive_timed_mutex、shared_timed_mutex14、shared_mutex17。都提供了lock、trylock、unlock方法。

  2. 为了防止死锁,lock和unlock需要成对调用,但是由程序员来保证,在代码复杂时难免会有疏漏。因此正如前面提到的,通过RAII技术封装,cpp也提供了如下RAII封装的互斥量管理对象。

  3. 互斥量管理:lock_guard11、unique_lock11、shared_lock14、scoped_lock

  4. lock_guard:该对象的构造函数会将传入的互斥体对象进行加锁,并在离开guard对象的作用域时,调用析构函数对互斥体解锁。那就需要保证mutex的生命周期长于guard作用域的生命周期

  5. 当出现某个线程对一个互斥体多次上锁时,会导致windows崩溃,而linux则会阻塞。实际开发应该避免,但是若需要多次加锁,则使用recursive_mutex,只有当解锁次数等于加锁次数时,其他线程才能持有该互斥体。

3.7.2、std::shared_mutex

  1. 其底层是操作系统提供的读写锁,也就是说当多个线程对共享资源读而少数写的时候,shard_mutex比mutex效率更高。

  2. 使用lock和unlock获取和解除写锁,也称独占锁,排他锁等。使用lock_shared和unlock_shared来获取和解除读锁,也称共享锁

  3. c++新标准引入了std::unique_lock和std::shared_lock。前者用于获取和释放写锁,后者用于获取和释放读锁,都是RAII技术的封装。

  4. unique_lock的所有权只能转让,不能复制,只能通过move语义或mutex构造,不能拷贝构造。可以通过函数构造,在函数中返回局部uniquelock对象,在运行时会生成临时的uniquelock对象,并调用移动构造函数转移所有权

  5. uniquelock比lockguard灵活很多,因为可以自己控制加锁和解锁,效率上差一些,但是内存占用多一些。

  6. uniquelock的第二个参数:std::adapt_lock、std::try_to_lock、std::defer_lock

  7. uniquelock的成员函数:lock,unlock,try_lock、release

3.7.3、std::condition_variable

  1. 使用该对象要绑定一个std::unique_lock或std::lock_guard对象。

  2. 与系统自带的条件变量相比,无需显示初始化和销毁

  3. 同样,使用wait会对锁进行操作。且同样的,需要用while(条件){wait}来防止虚假唤醒。

  4. 方法:wait、wait_for、wait_until、notify_one、notify_all。

可以使用花括号来减小锁的粒度(即锁住的代码少),提升代码实行效率

3.8、如何确保创建的线程一定能运行

使用条件变量来确保创建和运行。

3.9、多线程使用锁经验总结

  1. 减少锁的使用次数:1)加锁和解锁有一定的开销;2)临界区代码不能并发执行;3)若进入临界区次数过于频繁,会导致线程竞争激烈,而若竞争失败则要陷入阻塞让出CPU,所以执行上下文切换的次数要远远多于不使用互斥体情况下的次数。可以用无锁队列替换锁

  2. 明确锁的范围:

  3. 减小锁的粒度:减小临界区代码范围,代码范围越小,多个线程排队进入临界区的时间就会越短

  4. 避免死锁的建议:1)加锁记得解锁;2)线程退出时一定要及时释放持有的锁。3)多线程请求多个锁的顺序(方向)要一致。4)

  5. 避免活锁:指多个线程调用trylock时,由于互相谦让导致锁资源在一段时间内可用,也可能导致需要锁的线程拿不到锁。因此要避免让过多的线程使用trylock,避免对资源浪费。

3.10、线程的局部存储:

对于1个存在多个线程的进程来说,有时需要每个线程都自己操作自己的这份数据。这有点类似于C++类的实例属性,每个实例对象操作的都是自己的属性。我们把这样的数据称为线程局部存储(Thread Local Storage,TLS),将对应的存储区域称为线程局部存储区。

  1. Linux的线程局部存储:NTPL提供了一组函数接口创建pthread_key_create创建key,每个线程都可以使用key,指向一个全局变量,且需要制定destructor函数,以自定义释放key对应的value,防止内存泄漏。也就是说,键是全局共享的,键记录了槽位的分配情况,是进程唯一的,但是值是各线程都拷贝的。

  2. cpp11的thread_local关键字

  3. (1)对于线程变量,每个线程都会有该变量的一个拷贝,互不影响,该局部变量一直存在,直到线程退出。(2)系统的线程局部存储区域的内存空间并不大,所以尽量不要用这个空间存储大的数据块,如果不得不使用大的数据块,则可以将大的数据块存储在堆内存中,再将该堆内存的地址指针存储在线程局部存储区域。

3.12、线程池与队列系统的设计

  1. 线程池是一组线程,一组在程序生命周期内不会退出的线程,基本任务是,当有任务时,线程自动取任务执行,没有任务则进入阻塞或睡眠。用于不浪费系统资源的同时执行异步任务,即生成和执行是存在于整个程序生名周期的。即消费者生产者模式

  2. 对任务队列要加锁,核心任务有线程池的创建、投递任务、取任务并处理,清理线程池,清理任务队列。

  3. 环形队列:节省内存空间,在生产者和消费者速率差不多时使用

  4. 消息中间件:基于生产者/消费者模型衍生的队列系统在实际开发中很常用,以至于在一组服务中可能每个进程都需要一个这样的队列系统。既然如此,出于复用和解耦的目的,业界出现许多独立的队列系统,这些队列系统或以一个独立的进程运行,或以支持分布式的一组服务运行。我们把这种独立的队列系统称为消息中间件。这些消息中间件在功能上做了丰富的扩展,例如消费的方式、主备切换、容灾容错、数据自动备份和过期数据自动清理等,比较典型的有Kafka、ActiveMQ、RabbitMQ、RocketMQ等。如下所示是Kafka官网提供的介绍Kafka作用的一张图。

  5. 有了这种专门的队列系统,生产者和消费者将最大化解耦。利用消息中间件提供的对外消息接口,生产者只需负责生产消息,不必关心谁是消费者;消费者不必关心生产者是谁、何时有数据;队列系统本身也不必关心自己有多少生产者和消费者。当然,这种消息中间件还有其他非常优秀的功能,例如对数据的备份、负载和容灾容错措施。建议读者适当了解一两种开源队列系统的用法,尤其是其设计思路。

四、网络编程重难点解析

4.1、应该熟练掌握的socket函数

socket bind listen connect accept send recv select gethostbyname close shutdown setsockopt getsockopt

4.4、bind重难点分析

  1. 宏INADDR_ANY,in6addr_any。

  2. 回环地址

  3. 一般服务端的端口是固定的,若bind端口号写为0,则会自动选择一个可用的。但一般不这么做,因为服务器要对外服务。

  4. 不止服务端,客户端也可以绑定端口

4.5select函数的用法和原理

本节只关注linux的。

4.5.1 linux的select函数

  1. 用于检测在一组socket中是否有事件就绪,通常有三类事件,读事件就绪,写事件就绪,异常事件就绪。

  2. 1)读就绪:可读取、对端关闭、连接请求、未处理错误;2)写就绪:缓冲区可写、被关闭、非阻塞connect;

  3. int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);

  4. fd_set结构体:实际上是一个long int数组。根据long int的位数,以及数组的元素数量决定容纳多少socket。linux中long int占8字节,则16个元素的数组就可以实现8 8 16=1024个socket的监听,每个bit都对应一个fd的事件状态。

  5. 定位bit:先和8 8 = 64求商确定元素下标,再通过,然后与64求余数得到元素内的偏移量,将long int 1左移余数位,再用或操作将对应位置1。

  6. FD_SET宏:如上

  7. 当select返回时,使用FD_ISSET判断某个位是否有关心的事件,即检测对应位是否被置为1。

  8. 当断开各个客户端连接时,服务端的 select 函数对各个客户端 fd 检测时,仍然会触发可读事件,此时对这些 fd 调用 recv 函数会返回 0(recv 函数返回 0,表明对端关闭了连接,这是一个很重要的知识点),服务端也关闭这些连接就可以了。

  9. select会修改三个fd_set的内容,因此下次调用时需要重新将监听fd全清零后再置1。timeval也一样,都需要重新赋值。

  10. 若timeval的tv_sec和tv_usec都被设置为零,则检测后若没有感兴趣的事件,则会立即返回。若为NULL则会无限阻塞,直到监听事件发生。

  11. 在Linux上,select函数的第1个参数必须被设置为需要检测事件所有fd中的最大值加1。所以在上面的select_server.cpp中,每新产生一个clientfd,都会与当前最大的maxfd做比较,如果大于当前maxfd,则将maxfd更新为这个新的最大值。其最终目的是在select调用时作为第1个参数(加1)传进去。

  12. linux select的缺点:1)每次调用 select 函数时,都需要把 fd 集合从用户态复制到内核态,这个开销在fd较多时会很大,同时每次调用select函数都需要在内核中遍历传递进来的所有fd,这个开销在fd较多时也很大。2)单个进程能够监视的文件描述符的数量存在最大限制,在Linux上一般为1024,可以通过先修改宏定义然后重新编译内核来调整这一限制,但这样非常麻烦而且效率低下。3)select函数在每次调用之前都要对传入的参数进行重新设定,这样做也比较麻烦。4)在Linux上,select函数的实现原理是其底层使用了poll函数。

  13. 使用nc指令模拟客户端:nc -v以详细模式运行。正常情况使用nc 127.0.0.1 3000就可以连接到本地的3000端口。使用nc -l可以监听端口。同时可以使用echo 'string' | nc 127 .0.0.1 3000将string传给目标IP地址的目标端口,前提是对面在监听。

4.6socket的阻塞和非阻塞模式

socket的是否阻塞会影响connect、accept、send和recv函数。对阻塞和非阻塞模式下的各个socket函数表现进行深入理解是网络编程的重难点

阻塞即会阻塞线程到函数执行成功,非阻塞即不会阻塞,直接返回。

  1. 如何设置非阻塞:默认都是阻塞。在linux中,利用fcntl或者ioctl给创建的socket设置为非阻塞。

  2. 在linux创建时直接设置为非阻塞模式

  3. 将linux提供的accept的扩展函数accetp4返回的socket设置为非阻塞,将第四个参数设置为SOCK_NONBLOCK即可。

4.6.1、send和recv在阻塞和非阻塞下的表现

  1. send和recv本质上是操作内核和应用程序的缓冲区。内核缓冲区即TCP窗口。

  2. 在发送数据时,应答数据包会带上当前可用TCP窗口的大小,当窗口为零时,发送方仍然可以接着发送数据,但实际上只是再向自己的内核发送缓冲区拷贝数据

  3. TCP_NODELAY,禁用nagel算法,缓冲区的数据立刻就会被发送出去。

  4. send和recv会返回如下三种:大于零、0、小于零(-1)。1)要判断返回的字节数是否是拟发送的目标字节数,最好放在一个循环使用偏移量接着发送直到完毕。2)等于零意味着对端关闭了连接,但是若send发送0字节也是返回零;3)小于零则是调用出错。

  5. 阻塞与非阻塞的适用场景:非阻塞用于高qps;阻塞比较特殊,用于如问答模式。

4.7、发送零字节

  1. 对端关闭连接,而此端正好调用send发送,此时send会返回0;

  2. 本端尝试发送0字节,也会返回0;

  3. 但是,recv只会在对端关闭连接才会返回0,若对端发送0字节,则本端的recv不会收到0字节信息。

4.8、connect的阻塞与非阻塞

阻塞的connect会等到连接成功或失败才会返回,若网速较慢,则会在connect处阻塞几秒,因此实际项目中一般采用异步connect,即非阻塞模式。

  1. 非阻塞无论是否连接失败,会立即返回,若返回-1,不一定是出错,也可能是正在尝试连接。

  2. 随后可以调用select,在指定时间内判断该socket是否可写,则说明连接成功,反之则认为失败

七、单个服务的基本结构

4.2、TCP通信的基本流程

服务器:调用socket创建socket,利用bind绑定到ip+端口,调用listen监听,当有客户端来连接,调用accept,产生一个新的用于通信的socket,根据新的客户端使用send和recv进行通信,结束后,调用close关闭监听socket

客户端:调用socket创建socket,调用connect尝试连接,连接成功后调用recv或send,结束后调用close关闭socket

7.1、网络通信组件的效率问题

流行的框架如libevent、libuv、boost asio等。网络通信框架是由这些基础的socket API构成的。

7.4、Reactor模式

主流通信库如libevent、java的netty以及python的twisted等,都使用Reactor模式。是一种事件处理设计模式,在I/O请求到达后,服务器处理程序使用I/O复用技术同步的将这些请求派发给相关的请求处理程序。

  1. 表面上很简单,但是其思想不简单,解决了计算机中存在的一个普遍问题,请求太多,资源太少,即一个对外服务的程序,其接受的各种输入输出非常多,但是服务程序处理能力有限。即通常情况下,请求之和一般远远大于处理程序的数量,即高并发。

  2. 模块:资源请求事件、多路复用器与事件分发器、事件处理器。

7.5、one thread on loop思想

三、Linux C/C++服务器开发实战

第三章、多线程基本编程

第四章、TCP服务器编程

第五章、UDP服务器编程

第七章、服务器模型设计

第八章、iperf

第九章、HTTP服务器编程

第十章、基于Libevent的FTP服务器

第十一章、并发聊天服务器

四、剑指offer

第二章、数组

2.3、累加数组求子数组之和

前缀和

面试题10:和为k的子数组:输入一个整数数组和一个整数k,请问数组中有多少个数字之和等于k的连续子数组?例如,输入数组[1,1,1],k的值为2,有2个连续子数组之和等于2。

面试题11:输入一个只包含0和1的数组,请问如何求0和1的个数相同的最长连续子数组的长度?例如,在数组[0,1,0]中有两个子数组包含相同个数的0和1,分别是[0,1]和[1,0],它们的长度都是2,因此输出2。(将0变成-1做与题10一样的处理)

面试题12:输入一个整数数组,如果一个数字左边的子数组的数字之和等于右边的子数组的数字之和,那么返回该数字的下标。如果存在多个这样的数字,则返回最左边一个数字的下标。如果不存在这样的数字,则返回-1。例如,在数组[1,7,3,6,2,9]中,下标为3的数字(值为6)的左边3个数字1、7、3的和与右边两个数字2和9的和相等,都是11,因此正确的输出值是3。

面试题13:二维子矩阵的数字之和:输入一个二维矩阵,如何计算给定左上角坐标和右下角坐标的子矩阵的数字之和?对于同一个二维矩阵,计算子矩阵的数字之和的函数可能由于输入不同的坐标而被反复调用多次。例如,输入图2.1中的二维矩阵,以及左上角坐标为(2,1)和右下角坐标为(4,3)的子矩阵,该函数输出8。