C语言之巅,带你登顶并发、面向对象,C语言顶级功能(续1)
原书:Extreme C Taking You To The Limit In Concurrency, OOP, And The Most Advanced Capabilities Of C(2019出版) 作者:Kamran Amini 译者:冰颖机器人

第8章:继承与多态 本章讨论对象之间与对应类之间的关系,继承与多态:C语言中继承实现,多态函数。类之间关系有:to-be或is-a。 继承:继承关系属性to-be关系,在现有的对象或类基础上增加另外的属性与行为,因此也可以称为扩展关系。 Student包含所有Person属性,学生是人。 typedef struct { char first_name[32]; char last_name[32]; unsigned int birth_year; } person_t; typedef struct { char first_name[32]; char last_name[32]; unsigned int birth_year; char student_number[16];( 额外属性) unsigned int passed_credits; ( 额外属性) } student_t; 不管to-be关系用在那个领域,可能都是继承关系:超类或基类又或父类,子类或继承子类。 继承本质:本质上也是组合关系,如Person在Student中。继承关系等价与一个一对一组合关系。 typedef struct { char first_name[32]; char last_name[32]; unsigned int birth_year; } person_t; typedef struct { person_t person;(第一个属性,使得student指针可以强制转换为person指针,向上转换) char student_number[16]; (额外属性) unsigned int passed_credits;(额外属性) } student_t; 这种语法在C语言中至今仍有效,在嵌套结构体中使用结构体变量(非指针)非常有效。新结构体中使用结构体变量扩展了结构体变量。 子类型属性结构体指针转换为父类属性结构体就是向上转换,转换的前提是必须让子类第一个属性类型为父属性结构体。 student_t s; student_t* s_ptr = &s; person_t* p_ptr = (person_t*)&s;(转换) printf("Student pointer points to %p\n", (void*)s_ptr);(地址相同) printf("Person pointer points to %p\n", (void*)p_ptr);(地址相同) 转换后地址相同,也就是说student继承于person内存布局。这意味着我们可以通过student对象指针使用person的函数行为。换言之,Person行为函数可以被student对象重复使用。 struct person_t; typedef struct { struct person_t person; (编译出错,前向声明类型,不完整类型,只能用指针而非变量) char student_number[16]; (额外属性) unsigned int passed_credits; (额外属性) } student_t; 当你使用嵌套结构体变量实现继承,应该私有,对外不可见。因此,实现继承关系有:子类有权访问基类私有实现(类实际定义);子类仅可访问基类公有接口; C中第一种实现继承方法:结构体变量作为子类属性结构体第一个字段 Person类头文件: #ifndef EXTREME_C_EXAMPLES_CHAPTER_8_2_PERSON_H #define EXTREME_C_EXAMPLES_CHAPTER_8_2_PERSON_H struct person_t;(前向声明) struct person_t* person_new();(内存分配) void person_ctor(struct person_t*,const char* ,const char* ,unsigned int );(构造) void person_dtor(struct person_t*);(析构) void person_get_first_name(struct person_t*, char*);(行为函数) void person_get_last_name(struct person_t*, char*); unsigned int person_get_birth_year(struct person_t*); #endif 私有头文件(为部分逻辑代码知道结构体定义,而其他部分代码不知道): #ifndef EXTREME_C_EXAMPLES_CHAPTER_8_2_PERSON_P_H #define EXTREME_C_EXAMPLES_CHAPTER_8_2_PERSON_P_H typedef struct {(私有定义) char first_name[32]; char last_name[32]; unsigned int birth_year; } person_t; #endif Person类私有实现: #include <stdlib.h> #include <string.h> #include "ExtremeC_examples_chapter8_2_person_p.h"(私有头) person_t* person_new() {(内存分配) return (person_t*)malloc(sizeof(person_t)); } void person_ctor(person_t* person,const char* first_name, const char* last_name,unsigned int birth_year){(构造) strcpy(person->first_name, first_name); strcpy(person->last_name, last_name); person->birth_year = birth_year; } void person_dtor(person_t* person) {(析构) // Nothing to do } Student类头文件: #ifndef EXTREME_C_EXAMPLES_CHAPTER_8_2_STUDENT_H #define EXTREME_C_EXAMPLES_CHAPTER_8_2_STUDENT_H struct student_t;(前向声明) struct student_t* student_new();(内存分配) void student_ctor(struct student_t*,const char* ,const char*,unsigned int ,const char* ,unsigned int); void student_dtor(struct student_t*);(析构) void student_get_student_number(struct student_t*, char*);(行为函数) insigned int student_get_passed_credits(struct student_t*); #endif Student类实现: #include <stdlib.h> #include <string.h> #include "ExtremeC_examples_chapter8_2_person.h" #include "ExtremeC_examples_chapter8_2_person_p.h"(定义) typedef struct { person_t person;(继承,必须为第一个字段,否者不能使用Person类行为函数) char* student_number; unsigned int passed_credits; } student_t; person_ctor((struct person_t*)student,first_name, last_name, birth_year);(调用基类构造) person_dtor((struct person_t*)student);(调用基类析构) struct student_t* student = student_new();(内存分配) student_ctor(student, "John", "Doe",1987, "TA5667", 134)(构造) $ gcc -c ExtremeC_examples_chapter8_2_person.c -o person.o $ gcc -c ExtremeC_examples_chapter8_2_student.c -o student.o $ gcc -c ExtremeC_examples_chapter8_2_main.c -o main.o $ gcc person.o student.o main.o -o ex8_2.out $ ./ex8_2.out C中第二种实现继承方法:使用基类指针变量,子类实现独立与基类,并不能强制将子类指针指向基类。子类必须封装函数暴露内部基类私有属性。 Student类公有接口: #ifndef EXTREME_C_EXAMPLES_CHAPTER_8_3_STUDENT_H #define EXTREME_C_EXAMPLES_CHAPTER_8_3_STUDENT_H struct student_t;(前向声明) struct student_t* student_new();(内存分配) void student_ctor(struct student_t*,const char*,const char*,unsigned int /,const char*,unsigned int );(构造) void student_dtor(struct student_t*);(析构) void student_get_first_name(struct student_t*, char*);(行为函数) void student_get_last_name(struct student_t*, char*); unsigned int student_get_birth_year(struct student_t*); void student_get_student_number(struct student_t*, char*); insigned int student_get_passed_credits(struct student_t*); #endif Student实现: #include <stdlib.h> #include <string.h> #include "ExtremeC_examples_chapter8_3_person.h"(Person类公有头) typedef struct { char* student_number; unsigned int passed_credits; struct person_t* person;(使用指针) } student_t; student->person = person_new();(内存分配) person_ctor(student->person, first_name,last_name, birth_year);(构造) person_dtor(student->person);(析构) free(student->person);(释放内存) 两种方法比较:两者方法都是组合关系;第一种方法通过存储结构体变量来访问基类私有实现,第二种方法通过存储结构体指针指向基类属性结构体(不完整类型,无需依赖私有实现);第一种方法父类与子类之间强依赖,第二种方法两类之间独立;第一种方法,只能有一个父类,即单继承,第二种方法父类数量不限,即多继承;第一种方法父类结构体变量必须为子类属性接头体第一字段,第二种方法没有次限制;第一种方法父类子类对象无法分开,子类对象包含父类对象,子类对象指针指向父类对象;第一种方法可以直接使用父类行为函数,而第二种方法需子类新的行为函数来封装父类行为函数。 多态:并非两个类之间的关系,是不同行为间保持代码相同的技术,可以扩展代码或增加功能而不需冲编译基类所有代码。 何为多态?简单来说就是不同行为使用相同公有接口(行为函数集) Cat类、Duck类都Anima类的子类: struct animal_t* animal = animal_malloc(); animal_ctor(animal); struct cat_t* cat = cat_malloc(); cat_ctor(cat); struct duck_t* duck = duck_malloc(); duck_ctor(duck); 没有多态,每个对象都有调用sound行为函数。 animal_sound(animal);(没有多态) cat_sound(cat); duck_sound(duck); animal_sound(animal);(多态实现,相同函数,不同行为) animal_sound((struct animal_t*)cat); animal_sound((struct animal_t*)duck); 多态暗示Cat、Duck与第三个类Animal有继承关系,将duck和cat类型指针指转换为animal类型指针。 typedef struct { ... } animal_t; typedef struct { animal_t animal; ... } cat_t; typedef struct { animal_t animal; ... } duck_t; void animal_sound(animal_t* ptr) {(定义,这种并不是多态) printf("Animal: Beeeep"); } 为什么需要多态?我们向让代码段as is,不管使用派生基类的何种子类型。当增加新的子类型时并不想修改当前逻辑,或当子类型行为改变。新增特征零改变是不显示,使用多态尽可能降低改变数量。多态包含抽象概念,子类重写父类未实现的行为函数。由于想使用抽象类型,当处理抽象类型指针时,就需要一种调用合适实现的方法。 C语言中如何拥有多态行为:需要使用第一种方法,采用函数指针实现。函数指针需要保存在属性结构体字段中。 Animal类公有接口: #ifndef EXTREME_C_EXAMPLES_CHAPTER_8_4_ANIMAL_H #define EXTREME_C_EXAMPLES_CHAPTER_8_4_ANIMAL_H struct animal_t;(前向声明) struct animal_t* animal_new();(内存分配) void animal_ctor(struct animal_t*);(构造) void animal_dtor(struct animal_t*);(析构) void animal_get_name(struct animal_t*, char*);(行为函数,非多态) void animal_sound(struct animal_t*);(多态,支持子类重写) #endif Animal属性结构体: #ifndef EXTREME_C_EXAMPLES_CHAPTER_8_4_ANIMAL_P_H #define EXTREME_C_EXAMPLES_CHAPTER_8_4_ANIMAL_P_H typedef void (*sound_func_t)(void*);(指向需要类型的函数指针) typedef struct { char* name; sound_func_t sound_func;(函数指针,指向真实行函数指针,每个子类重写该函数) } animal_t; #endif Animal类: void __animal_sound(void* this_ptr) {(默认实现) animal_t* animal = (animal_t*)this_ptr; printf("%s: Beeeep\n", animal->name); } Cat类: typedef struct { animal_t animal; } cat_t; void __cat_sound(void* ptr) {(重写) animal_t* animal = (animal_t*)ptr; printf("%s: Meow\n", animal->name); } Duck类: typedef struct { animal_t animal; } duck_t; void __duck_sound(void* ptr) {(重写) animal_t* animal = (animal_t*)ptr; printf("%s: Quacks\n", animal->name); } 编译: $ gcc -c ExtremeC_examples_chapter8_4_animal.c -o animal.o $ gcc -c ExtremeC_examples_chapter8_4_cat.c -o cat.o $ gcc -c ExtremeC_examples_chapter8_4_duck.c -o duck.o $ gcc -c ExtremeC_examples_chapter8_4_main.c -o main.o $ gcc animal.o cat.o duck.o main.o -o ex8_4.out $ ./ex8_4.out C++中多态作为基本的特征,对于支持多太类行为函数需要用特殊关键字暗示,这些函数也称为虚函数。虚函数需要被编译器追踪,当重写时,对应的对象中的指针被替换成实际定义,用于运行时执行正确的函数版本。
第9章:c++中抽象和面向对象编程 本章介绍新的编程方式,探索C++如何实现面向对象概念:抽象帮助书籍对象模型,最大扩展性与自小依赖;c++编译器如何实现中面向对象概念。 抽象:OOP中,抽象本质为处理抽象数据类型。在基于类面向对象中,抽象数据类型等价与抽象类。抽象类为特殊的类,缺少创建对象完整的信息,不能直接用于创建对象。之所以需要抽象类,是因为使用抽象或普通数据类型时,代码之间避免强依赖。 Human类与Apple类:人吃苹果,人吃橙子。人还可以吃很多其他水果,如果不用抽象类,就必须增加更多关系,而如果有Fruit类,那就只需要建立Human与Fruit关系即可:人吃Fruit;Fruit抽象类没有形状、味道、气味、颜色等具体水果的特征。进一步抽象,覆盖沙拉巧克力,建立Eater类:人吃可食类。我们可以进一步抽象对象模型,找到比所需要解决问题更抽象数据类型,过度抽象。可能是为了以后需求,但可能会引起问题,尽量避免过度抽象。维基百科:程序中每个重要的函数都应该在源代码中的一个地方实现。 在类似的函数由不同的代码段执行的情况下,通常通过将不同部分抽象出来组合为一个代码通常是有益的。 在编程语言中,继承与多态是创建抽象必要的两种能力。抽象类型的行为没有默认的实现,因此不能直接创建对象。多态扮演重要角色,通过虚函数,允许子类重写父类行为。随着越来越抽象,没有属性和仅包含没有默认定义的虚函数,这就是接口。在软件项目中,暴露函数,但不提供任何实现,可用于创建部件之间依赖。想抽象类型,我们没法从接口中创建对象,这点C语言是做不到的。 Eatable类接口: typedef enum {SWEET, SOUR} taste_t; typedef taste_t (*get_taste_func_t)(void*);(函数指针类型) typedef struct { get_taste_func_t get_taste_func;(虚函数指针) } eatable_t; eatable_t* eatable_new() { ... }(内存分配,移除出公有接口) void eatable_ctor(eatable_t* eatable) { eatable->get_taste_func = NULL;(构造中虚函数指针默认为空) } taste_t eatable_get_taste(eatable_t* eatable) { return eatable->get_taste_func(eatable);(虚行为函数) } 抽象类型创建对象将崩溃: eatable_t *eatable = eatable_new(); eatable_ctor(eatable); taste_t taste = eatable_get_taste(eatable); (段错误) free(eatable); C语言继承实现中,移除分配函数,只有子类可以从父类属性结构体中创建对象。为了使得外部代码不能创建类型对象,需要将属性结构体前向声明,使其变成不完整类型。因此也需要移除公有内存分配函数。 C语言中对于抽象类,需要将虚函数指针置空,在更高层抽象中,接口所有函数指针都为空。为阻止外部代码创建抽象类型对象,应当将内存分配函数从公有接口中移除。 C++中面向对象构造:g++编译器(非C++标准),支持封装、继承、多态与抽象。c与C++实现面向对象实现方法高度一致。设定环境为64位Linux系统。 封装:通过比较c与c++程序生成的汇编指令来度量相似性。 矩形类C版本: #include <stdio.h> typedef struct { int width; int length; } rect_t; int rect_area(rect_t* rect) { return rect->width * rect->length; } 矩形类C++版本: #include <iostream> class Rect { public: int Area() { return width * length; } int width; int length; }; 编译代码: $ gcc -S ExtremeC_examples_chapter9_1.c -o ex9_1_c.s $ g++ -S ExtremeC_examples_chapter9_1.cpp -o ex9_1_cpp.s 查看汇编,,两者高度一致: $ cat ex9_1_c.s ... rect_area: movq %rdi, -8(%rbp)(系统寄存器指针参数传递宽长) movq -8(%rbp), %rax movl (%rax), %edx movq -8(%rbp), %rax movl 4(%rax), %eax ... $ cat ex9_1_cpp.s ... _ZN4Rect4AreaEv: movq %rdi, -8(%rbp)(系统寄存器指针参数传递宽长) movq -8(%rbp), %rax movl (%rax), %edx movq -8(%rbp), %rax movl 4(%rax), %eax ... 继承:c++实现继承与C采用第一字段结构体方法实现相似。因为,C++子类指针可转为父类指针,且子类可访问父类私有定义。然而C++支持多继承,似乎是更为复杂。 C单继承版本: #include <string.h> typedef struct { char c; char d; } a_t; typedef struct { a_t parent; char str[5]; } b_t; int main(int argc, char** argv) { b_t b; b.parent.c = 'A'; b.parent.d = 'B'; strcpy(b.str, "1234");(gdb后设置断点查看内存布局) return 0;
} C++单继承版本: #include <string.h> class A { public: char c; char d; }; class B : public A { public: char str[5]; }; int main(int argc, char** argv) { B b; b.c = 'A'; b.d = 'B'; strcpy(b.str, "1234");(gdb后设置断点查看内存布局) return 0; } 编译调试查看内存布局:两者高度相似 $ gcc -g ExtremeC_examples_chapter9_2.c -o ex9_2_c.out $ gdb ./ex9_2_c.out (gdb) x/7c &b 0x7fffffffe261: 65 'A' 66 'B' 49 '1' 50 '2' 51 '3' 52 '4' 0 '\000‘ $ g++ -g ExtremeC_examples_chapter9_2.cpp -o ex9_2_cpp.out $ gdb ./ex9_2_cpp.out (gdb) x/7c &b 0x7fffffffe251: 65 'A' 66 'B' 49 '1' 50 '2' 51 '3' 52 '4' 0 '\000' C++中属性与行为函数也是分离的,类中属性不管放在哪个位置,都将放在相同的特定对象块中,函数总是独立于属性。 C多继承版本: typedef struct { ... } a_t; typedef struct { ... } b_t; typedef struct { a_t a; b_t b; ... } c_t; c_t c_obj; a_t* a_ptr = (a_ptr*)&c_obj;(指向相同地址) b_t* b_ptr = (b_ptr*)&c_obj;(危险,如果通过该指针访问b_t字段将导致未定义行为) c_t* c_ptr = &c_obj; c_t c_obj; a_t* a_ptr = (a_ptr*)&c_obj; b_t* b_ptr = (b_ptr*)(&c_obj + sizeof(a_t));(安全版本) c_t* c_ptr = &c_obj; C++多继承版本: #include <string.h> class A { public: char a; char b[4]; }; class B { public: char c; char d; }; class C { public: char e; char f; }; class D : public A, public B, public C { public: char str[5]; }; D d; d.a = 'A'; strcpy(d.b, "BBB"); d.c = 'C'; d.d = 'D'; d.e = 'E'; d.f = 'F'; strcpy(d.str, "1234"); A* ap = &d; B* bp = &d; C* cp = &d; D* dp = &d;(后设gdb调试断点) 编译调试:多继承所有属性放在相邻位置,将A类型对象放D类对应的第一个属性字段位置。 $ g++ -g ExtremeC_examples_chapter9_3.cpp -o ex9_3.out $ gdb ./ex9_3.out (gdb) x/14c &d 0x7fffffffe25a: 65 'A' 66 'B' 66 'B' 66 'B' 0 '\000' 67 'C' 68 'D' 69 'E' 0x7fffffffe262: 70 'F' 49 '1' 50 '2' 51 '3' 52 '4' 0 '\000' (gdb) print ap $1 = (A *) 0x7fffffffe25a(第1个字段) (gdb) print bp $2 = (B *) 0x7fffffffe25f(第2个字段) (gdb) print cp $3 = (C *) 0x7fffffffe261(第3个字段) (gdb) print dp $4 = (D *) 0x7fffffffe25a C中转换与C++中不同,C++转换非被动,可以执行一些指针偏移算术转换,C转换是被动的需要手动完成。 多态:C++使用更为复杂的机制实现多态,基本思想依然是相同的。 C版本:并非所有行为函数都支持多态,虚方法,虚函数 typedef void* (*func_1_t)(void*, ...);(函数指针类型,void*通用指针,可替换其他任何指针类型) typedef void* (*func_2_t)(void*, ...);(函数指针类型) typedef void* (*func_3_t)(void*, ...);(函数指针类型) typedef struct { ...(属性) func_1_t func_1;(函数方法) func_2_t func_2; func_n_t func_t; } parent_t; void* __default_func_1(void* parent, ...) {(默认定义)} void* __default_func_2(void* parent, ...) { (默认定义)} void* __default_func_n(void* parent, ...) {(默认定义)} void parent_ctor(parent_t *parent) {(构造) ...(初始化属性) parent->func_1 = __default_func_1;(默认虚行为函数) parent->func_2 = __default_func_2; parent->func_n = __default_func_n; } void* parent_non_virt_func_1(parent_t* parent, ...) { // Code }(公有非虚行为函数) void* parent_func_1(parent_t* parent, ...) {(真正的虚行为函数) return parent->func_1(parent, ...); } typedef struct {(子类) parent_t parent; ...(子类属性) } child_t; C++中为每个虚函数增加函数指针属性,使用更为智能方式存储这些指针,数组或叫虚函数表vtable。当对象创建时一并创建虚函数表,具体为调用基类构造时生成,而后作为子类构造部分。应避免在父类或子类中调用多态方法,指针可能没有更新导致指向错误定义。 抽象类:C++中为纯虚函数。所有的成员函数都为纯虚,那就是接口类。 enum class Taste { Sweet, Sour }; class Eatable { public: virtual Taste GetTaste() = 0;(等于0表示纯虚函数) }; class Apple : public Eatable { public: Taste GetTaste() override { return Taste::Sweet; } }; 纯虚函数与虚函数类似,地址保存在虚函数表中。但纯虚函数初始化指针为null空。C编译器,不支持抽象类型。
第10章:Unix历史和架构 C语言与Unix关系密切,Unix是第一个使用相对高级编程语言C实现的系统,C也是为此而设计的,因Unix而著名。回到70、80年代,贝尔实验室Unix工程师使用别的编程语言代替C,那么今天讨论的就是那个语言,而不是C了。C语言的成功Unix起了很大的作用。引用C语言先驱Dennis M.Ritchie的话就是“毫无疑问,Unix本身的成功是最重要的因素;它使C语言可供数十万人使用。 相反,当然,Unix使用C语言以及带来的可移植各种机器的能力,对于Unix的成功很重要”。本章将讲述:Unix历史,C语言如何发明;C如何基于B和BCPL开发出来;Unix洋葱架构及其背后的设计哲学;用户应用层与内核环,程序员使用内核环暴露的API,SUS与PSIX标准;讨论内核层与Unix内核有的特征用与功能;Unix设备与其如何使用。 Unix历史:Unix伴随者C存在。 Multics操作系统与Unix:在Unix之前,1964年,MIT、通用电气、贝尔实验室合作建立Multics操作系统。Multics操作系统可参与实际工作的安全操作系统,取得巨大成功。乃至今天,每个操作系统都间接通过Unix借鉴Multics中的思想。 1969年贝尔实验室中,Unix先驱Ken Thompson和Dennis Ritchie建立Multics,随后贝尔实验退出Multics项目,建立更简单高效的操作系统,那就是Unix。Multics与Unix系统异同: 内部结构都是基于洋葱架构(内核哈shell环),编程可以在shell环顶部编写自己的程序,两者均匀实用命令,如ls和pwd;Multics需要更多资源机器才能工作,普通机器不大可能安装;Multics设计复制,这也是贝尔实验室员工退出的原因,Unix保持简单。值得一提的是贝尔实验室最新发布操作系统叫Plan9,也是基于Unix项目。 第一个版本的Unix使用汇编语言编写,那时候还没有C语言,直到1973年Unix第四版才是用C写的。 BCPL与B:为了写编译器,Martin Richards创造了BCPL编程语言。贝尔实验室在做Multics项目被介绍BCPL,当退出该项目开始写Unix时因为觉得编写操作系统用编程语言是反模式的,所以采用汇编来写。在Multics项目开发中,使用PL/1来开发,展示了使用高级语言可以成功编写操作系统。Unix开发受此启发。Ken Thompson和Dennis Ritchie尝试用编程语言而非汇编写操作系统,他们尝试使用BCPL,但发现需要做写修改才能在迷你电脑DEC PDP-7中使用,这就导致B产生。B作为系统编程语言也有不足,B是无类型,意味着每个操作只能用字而非字节,这使得在不同字长度的机器难以使用。为此进一步修改,诞生NB(新B)语言,从B语言中导出结构。最后,在1973年,第四版Unix使用C语言开发,其中仍有很多汇编代码。 C语言之路:B语言缺点,导致C语言创立。B语言在内存中只能用字,使用字节仍是梦想,当时硬件基于字策略处理内存;B是无类型,或者说单类型语言;无类型意味着多字节算法(字符操作)用B写低效;B比支持浮点操作;PDP-1机可基于字节处理内存,B语言在处理内存字节低效,B指针仅能处理内存字,而非字节,要想访问字节,必须先转换对于的字索引。Dennis Ritchie创造新的语言,NB或者 new B,最后命名为C。C语言开发尽量克服B不足,白痴系统开发标准语言。在后面的十年内,Unix系统完全采用C编写,后面所有基于Unix操作系统都与C绑定。 至今,没有语言可以与C媲美,高级语言Java、Python、Ruby不能写设备驱动或内核模块,他们自己仍建立在C写的底层之上。C编程语言是ISO标准。 Unix架构:Multics项目对操作系统思考有着革命性方式,Ken Thompson和他的同事将其带入Unix中。 哲学:Unix主要为编程这而非普通终端用于而设计开发的;Unix系统由小的和简单的程序组成;一个复杂任务可以分解小的简单程序链式顺序执行;小段程序可以的输出作为其他程序的输入,链式得以继续;Unix是面向文本,所有配置都是文本文件,文本命令行,shell脚本;Unix选择简单而非完美;为Unix兼容操作系统写程序应该使用简单。这些本质上都在反应在C语言中。 Unix洋葱:洋葱模型,由很多环祖先,每个环包嵌套内部环。

硬件:核心模型;Unix使得用户轻松与硬件交互; 内核:操作系统最为重要的部分,封装暴露硬件相关的功能,最高优先级使用系统资源; Shell:允许用户应用与内核交互,并使用很多服务,由工具集组成,也包括c语言写的库; 简单Unix规范:shell暴露标准和确定的程序接口,使得Unix程序可移植或可咋爱不同Unix实现中编译; 用户应用程序:数据库服务,web服务,邮件服务,web浏览器,工作表程序,word编辑程序。 Shell接口到用户应用程序:终端命令行或特定GUI程序,shell API提供内存、CPU、网络适配器、硬件设备访问,所有Unix系统由着相同的Shell 环API。stdio.h并非c里面的部分,它只是SUS标准特定的C标准库。SUS v4中API:系统接口(1191个C99函数),头接口(stdio.h, stdlib.h, math.h, string.h),实用接口(160个实用程序,命令行,mkdir, ls , cp, df, bc)、脚本接口(用于编写shell脚本,shell编程语言,shell脚本语言),XCURSES接口(C程序与用户交互,基于文本GUI,安全Shell)。Unix类操作系统,Linux为SUS标准子集,POSIX。1990年代,Windows也是POSIX兼容的,只是后面被丢弃了。 内核接口到shell环:libc(shell环中函数)调通过系统调用内核函数。sleep、printf、malloc由libc实现,触发系统调用内核。truss+可执行对象文件可以看到程序中系统调用情况。man nanosleep系统调用手册查看nanosleep。 $ git clone https://github.com/freebsd/freebsd $ cd freebsd $ git reset --hard bf78455d496 $ cd lib/libc $ grep sys_nanosleep . -R BSD中以__sys开始的函数表示系统调函数。 内核:内核环的主要目的就是管理系统中的硬件,提供函数给系统调用。内核也是进程,像其他进程一样序列执行指令,但又不同于普通用户进程。内核进程首先被加载执行,用户进程创建在内核进程加载执行之后;内核进程只有一个,用户进程可以同时有多个;内核进程创建,通过拷贝内核镜像,boot加载到到主内存中,用户进程使用exec或fork系统调用;内核进程处理执行系统调用,用户进程唤醒系统调用和等等内核进程执行处理;在优先级模型中内核进程可看到物理内存和所有硬件,用户进程只能看到由物理内存映射的一部分虚拟内存,但并不知道物理内存布局,用户进程可以看做在沙箱里执行,进程之间内存不可见。内核空间与用户空间隔离开来,内核具有最高优先级访问系统资源,而用户空间最低优先级访问。

典型Unix内核可以通过内核执行任务查看。内核执行任务不止硬件管理 。 进程管理:用户进程通过系统调用由内核创建,运行进程前为新进程分配内存和加载操作指令; 进程间通信(IPC):用户进程交互数据,通过共享内存,管道,Unix的sockets: 调度:多任务操作系统,内核管理CPU核心访问与平衡; 内存管理:毫无疑问,这是内核关键的任务,分割分配页,给进程分配新页,堆分配,释放内存: 系统启动:内核镜像加载到主内存中,内核进程启动,初始化用户空间,PID为1,如Linux中调用init; 设备管理:除了CPU和内存,内核通过抽象管理硬件,/devl路径存储设备映射文件,硬盘,网络适配器,usb设备等; 内核单元包括内存管理单元MMU管理可用物理内存,进程管理单元在洪湖空间创建进程和分配只有,进程间通信IPC。字符和块设备通过设备驱动暴露各种功能函数。文件系统单元本质上也是内核一部分,他们是块与字符设备抽象。

硬件:Unix旨在使用相同实用程序集与命令行,提供硬件抽象和透明访问。计算机中硬件可分为两类,强制性设备和外围设备。CPU和主内存是强制性设备,其他硬件向硬盘、网卡、鼠标、监视器、显卡、WiFi模块都是外围设备。文件系统是Unix内存操作本质,不一定要求有硬盘。内存管理和调度单元分别负责管理内存和CPU。外围设备通过呢设备文件链接到Unix系统,在/dev路径下查看。 $ ls -l /dev 并非所有设备都有对应的实物,硬件设备层抽象使得Unix可以由虚拟设备。如虚拟网卡,没有对应的实物,但可以执行网络数据操作,VPN方式。 每个设备在/dev目录下都有自己的文件,由c和b前缀分别表示字符设备和块设备。字符设备一个个字传输数据,如串口和并口。块设备同那个数据块传输数据而不是一个一个字节,硬盘,网卡,相机等都是块设备。l前缀表示链接其他设备符号,d表示包含其他设备文件的目录。 用户进程使用设备文件访问对应的硬件,这些文件可以写入或读取设备数据。
第11章:系统调用与内核 本章学习结束,你将可以分析程序发起的系统调用,可以解释进程如何在Unix环境生存与变化,也可以直接使用系统调用或通过libc实现。我们也讨论Unix内核开发,展示如何向Linux内核增加新的系统调用。本章末尾,我们将讨论单内核和微内核之间差异,以Linux为例介绍单内核,编写内核模动态加载与卸载。 系统调用:系统调用从用户执行进程到内核进程之间迁移。内核空间与用户空间堆系统调用理解至关重要,编写系统调用及内核新增功能后再编写新的系统调用。 系统调用细节:用户应用和shell术语用户空间,同样,内核环和硬件环术语内核空间。这中区分有一个规则,最里面的环可以直接访问用户空间,而用户空间进程不能直接访问硬件、内核数据结构、算法,只能通过系统调用来访问。 如用户程序从网络socket读取字节,不是直接从网卡类读取,而是通过内核读取拷贝到用户空间,程序读取使用。另一个例子,从硬盘中读取文件,在用户应用环里编写程序,使用libc I/O功能调入如fread类函数,最终进程运行在用户空间,当程序调用fread函数时,libc的实现被触发执行。fread接收第一个参数是需要打开的文件描述,第二个参数是进程中分配缓存地址,第三个个参数是缓存长度。 内核控制部分用户进程执行,从用户空间接收参数并保存在内核空间中,内核通过访问文件系统单元读取文件,当读取操作完成后,数据被拷贝到用户空间缓存中,退出系统调用并把执行控制权交个用户进程。当系统调用繁忙的时候,用户进程通过等待,也就是说系统调用是阻塞的。我们只有一个内核执行系统调用所有逻辑;如果系统调用是阻塞的,当系统调用繁忙或未完成时用户进程中调用者需要等待,当系统调用非阻塞时系统调用立即返回,用户进程必须执行额外的系统调用来检查结果;输入输出数据拷出或拷入用户空间;用户空间与内核空间内存分离,用户进程只能访问用户空间内存,要完成系统调用可能需要多次转换。 借用C标准,直接系统调用:程序调用系统调用不需要通过Shell环,这可能有点反模式,但当系统调用未在libc中提供,用户程序可以直接调用系统调用。在每个Unix系统中,有些特定的方法直接发起系统调用,如<sys/syscall.h>头文件中的syscall函数。打印Hello World例子可以不用位于shell环POSIX标准的libc标准输出,即printf函数,而是直接发起系统调用,这只能用在兼容的Linux机中,而非其他Unix系统。
#define _GNU_SOURCE(glibc,非POSIX或SUS标准) #include <unistd.h> #include <sys/syscall.h>(非POSIX标准) int main(int argc, char** argv) { char message[20] = "Hello World!\n"; syscall(__NR_write, 1, message, 13);(写系统调用,三个参数:标准输出文件描述,缓存指针,字节长度) return 0; } 编译: $ gcc ExtremeC_examples_chapter11_1.c -o ex11_1.out $ ./ex11_1.out $ strace ./ex11_1.out(跟踪系统调用) 需要主要的是,用户程序永远不要直接使用系统调用,系统调用前后由很多步骤需要完成。libc实现了因Unix系统而异这些步骤,如果不使用libc的话就必须自己实现。 系统调用函数内部:glibc中syscall,汇编写的函数。汇编语言可以与c语句一起出现在C源文件中,这也是C语言重要的特征,适合写操作系统。 syscall.S 文件:https://github.com/lattera/glibc/blob/master/ ,sysdeps/unix/sysv/linux/x86_64/syscall.S #include <sysdep.h> .text ENTRY (syscall) movq %rdi, %rax(系统调用号赋值给rax寄存器) movq %rsi, %rdi(参数1-5拷贝到不同寄存器) movq %rdx, %rsi movq %rcx, %rdx movq %r8, %r10 movq %r9, %r8 movq 8(%rsp),%r9(参数6入栈) syscall(函数调用) cmpq $-4095, %rax(%rax错误校验,中断) jae SYSCALL_ERROR_LABE(跳转到错误处理) ret(返回被调用点) PSEUDO_END (syscall) 内核支持系统调用参数超过6个,glibc无法提供某些内核功能,如果需要支持就得修改。幸好,6个参数对于大多数系统调用是够用的,我么也可以传结构体变量指针在用户空间分配内存。中断处理是在CPU中执行,拥堵代码初始化系统调用后就退出CPU(内核指令加载到CPU中,用户空间应用结束执行),内核开始执行该任务,这就是系统调用主要机制。 向Linux中增加系统调用:用c编写代码运行在内核空间。实际上,C编写程序运行于用户空间称为C编程或C开发,运行在内核空间称为内核开发。 内核开发:内核开发不同于一般C程序开发,六个关键不同:所有程序都运行在一个内核进程中,也就是说内核代码一旦崩溃,就需要重启;内核环中没有C标准库,如glibc,SUS和POSIX标准在这里无效,你不能包含任何libc头文件,如stdio.h或string.h。你需要在内核头中,开发各种操作的函数集,当然这没有统一标准。Linux中用printk内核缓存写消息,FreeBSD需要用<sys/system.h>中的printf(非libc中printf),与macOS系统中XNU内核开发中os_log对于;你可以读写内核文件,但不是通过libc函数,Unix内核有自己的访问内核环文件方法;你有可以访问所有物理内存和内核环中其他服务;内核中没有系统调用机制;内核进程是通过由boot加载器拷贝内核镜像到物理内存来创建,也就是说你不能跳过重启系统重新加载内核镜像来增加新的系统调用,但你可以在内核运行中新增或移除内核模块。 内核开发与通用c开发流程不同,测试逻辑比较困难,错误代码可能导致系统崩溃。 为Linux写一个Hello World系统调用:请在虚拟机下实验。linux是内核,只能安装在Unix类操作系统内核环中。Linux发行版有他指定的Linux内核版本、shell环中GNU libc版本与Bash(GNU shell)。每个Linux发行版通常将所有的用户程序拷贝到它的外部环中。64位Ubuntu 18.04.1Linux发行版。 $ sudo apt-get update(确定必要的安装包) $ sudo apt-get install -y build-essential autoconf libncurses5-dev libssl-dev bison flex libelf-dev git ... ... apt为主要的Debian Linux发行版包管理指令,sudo为使用超级用户模式运行命令的实用程序。 $ git clone https://github.com/torvalds/linux(拷贝5.3版本) $ cd linux $ git checkout v5.3(检查) $ ls(查看文件,fs , mm , net , arch等) 首先需要选择一个唯统调用的唯一数字识别号,在include/linux/syscalls.h头文件末尾增加系统调用函数声明: #ifndef _LINUX_SYSCALLS_H #define _LINUX_SYSCALLS_H struct epoll_event; struct iattr; struct inode; ... asmlinkage long sys_statx(int dfd, const char __user *path, unsigned flags, unsigned mask, struct statx __user *buffer); asmlinkage long sys_hello_world(const char __user *str, const size_t str_len, char __user *buf, size_t buf_len);(四参数:用户空间分配缓存输入字符串指针,缓存字符串长度,用户空间再次分配缓存字符串和长度,__user为用户空间内存地址类,系统调用返回为整形值,0为成功) #endif 在跟目录下创建hello_world $ mkdir hello_world $ cd hello_world 创建sys_hello_world.c #include #include <linux/kernel.h>(printk函数) #include <linux/string.h>(strcpy, strcat, strlen函数) #include <linux/slab.h>(kmalloc, kfree函数) #include <linux/uaccess.h>(copy_from_user, copy_to_user函数) #include <linux/syscalls.h>(SYSCALL_DEFINE4宏定义) SYSCALL_DEFINE4(hello_world, const char __user *, str, const unsigned int, str_len, char __user *, buf, unsigned int, buf_len) {(系统调用定义:输入名,长度,输出缓存,长度,DEFINE4后缀表示接收4个参数) char name[64];(保存输入内容缓存内核栈变量) char message[96];(保存输出消息内核栈变量) printk("System call fired!\n"); if (str_len >= 64) { printk("Too long input string.\n"); return -1; } if (copy_from_user(name, str, str_len)) {(从用户空间拷贝数据到内核空间) printk("Copy from user space failed.\n"); return -2; } strcpy(message, "Hello ");(创建最后的消息) strcat(message, name); strcat(message, "!"); if (strlen(message) >= (buf_len - 1)) {(确认最后消息与的输出二进制是否匹配) printk("Too small output buffer.\n"); return -3; } if (copy_to_user(buf, message, strlen(message) + 1)) {(从内核空间到用户空间拷回消息) printk("Copy to user space failed.\n"); return -4; } printk("Message: %s\n", message);(在内核日志中打印发送的消息) return 0; } 为了使系统调用能工作,需要更新系统调用表:在arch/x86/entry/syscalls/syscall_64.tbl末尾增加 999 64 hello_world __x64_sys_hello_world(以此是定义的唯一识别号,64bit架构,函数名,系统调用函数) $ cat arch/x86/entry/syscalls/syscall_64.tbl(查看修改后) __x64_前缀表示仅用于x64系统。Linux内核需要使用Make构建系统编译所有文件并构建最终的内核镜像。在hello_world目录下创建Makefile文件,写入内容obj-y := sys_hello_world.o。在根目录下的主Makefile文件增加hello_world目录,找到core-y += kernel/certs/mm/fs/ipc/security/crypto/block/一行,需增加core-y += kernel/certs/mm/fs/hello_world/ipc/security/crypto/block/ 构建内核:回到内核根目录,构建过程需要特征列表和单元配置。 $ make localmodconfig(基于当前Linux内核配置创建目标配置,如果配置值已经存,当有新配置值是会询问是否更新,接受直接用enter) 现在可以开始构建了,由于Linux内核包含很多源文件,构建过程将花费数小时,因此,我们需要并行编译。如果用的是虚拟机,需要配置多核。 $ make -j4(构建前需要确认安装要求的包,否者编译报错,4个job并行编译 $ sudo make modules_install install(安装新内核并重启) $ uname -r(查看当前内核版本) $ sudo reboot(重启) boot加载器捕获用老版本内核,如果由更高版本内核,你就需要手动指定。 使用内核调用: #define _GNU_SOURCE(为了能使用非POSIX) #include <stdio.h> #include <unistd.h> #include <sys/syscall.h>(非POSIX) int main(int argc, char** argv) { char str[20] = "Kam"; char message[64] = ""; int ret_val = syscall(999, str, 4, message, 64);(调用hell world 系统调用,999为定义的唯一识别号,不超过64字节) if (ret_val < 0) { printf("[ERR] Ret val: %d\n", ret_val); return 1; printf("Message: %s\n", message); return 0; } $ gcc ExtremeC_examples_chapter11_2.c -o ex11_2.out(编译,运行) $ ./ex11_2.out $ dmesg(查看内核日志) $ strace ./ex11_2.out(跟踪系统调用过程) Unix内核:内核设计没有统一标准,每个内核有自己的方式处理系统调用,但无一例外都需要暴露系统调用接口,如单内核与微内核。 单内核与微内核:单内核由包含多核小单元的内存空间的一个内核进程组成,微内核是把服务(文件系统,设备驱动,进程管理等)放到用户空间使得内核进程尽可能小。这两者各有优缺点,相关争论可以追溯到1992年第一个Linux版本发行的时候。 单内核由单进程组成,包括所有内核提供的服务,大多早起Unix内核采用这种方式,微内核将服务分离出去;单内核进程运行在内核空间,而微内核服务进程(内存管理,文件系统等)通常运行在用户空间,有些操作系统更像微内核;单内核一般更快,因为所有内核服务运行在内核空间,但微内核用户空间与内核空间需要消息传递;单内核中,所有设备驱动(第三方厂商编写)被加载到内核中运行,一旦任何设备驱动或内核其他单元存在缺陷,可能导致内核崩溃,微内核所有设备驱动和其他单元运行在用户空间,推测这可能是为什么单内核不用在关键任务项目的原因;在单内核,注入小段恶意代码足以损害整个内核,乃至整个系统,但是这在微内核中却很少发生;单内核中,即便是一点内核源代码改变都需要重新编译整个内核,以便生成新的内核镜像,并且加载新的镜像需要重启,而微内核仅需要编译变化的服务进程,不需要重启加载新的服务进程;单内核中一个相似的功能可以从扩展内核模块获得; 微内核最著名的例子就是MINIX,由Andrew S. Tanenbaum编写,最初作为教育操作系统,Linusx Torvalds使用MINIX作为它编写的Linux内核开发环境。 Linux:增加系统调用新方法,写内核模块插件,动态加载到内存中。 内核模块:单内核通常为内核开发者配有热插拔新功能的能力,这种插件单元称为内核模块。并不像微内核服务进程,采用IPC技术实现分离进程间通信,内核模块是已编译的内核对象文件,可以动态加载到内核进程中,内核对象文件既可以是静态的也可以是动态加载的。内核中内核模块错误操作也可能导致内核崩溃。一般对象文件与内核对象文件是两个概念。内核模块之间通信方式不同于系统调用,不能被函数或给定API调用,内核模块由三种通信方式:/dev directory下设备文件,内核模块主要用于设备驱动开发,这也就是为什么设备是内核模块最常用的通信方式;procfs入口点,/proc目录下入口点用于读取指定内核模块元信息,这些文件可用于给内存模块传递元信息或控制命令;sysfs入口点,Linux中另一个文件系统,运行脚本和用户控制用户进程和其他内核相关单元,如内核模块,这可以认为是procfs的另一个版本。 内核模块不限于Linux系统,非单内核如FreeBSD也有内核模块机制。 为Linux增加内核模块:在procfs中创建入口点。内核模块被编译到内核对象文件中,运行时直接加载,只要不导致内核崩溃,加载内核模块后无需重启,对于卸载也是如此。 第一步,为内核模块相关文件创建目录: $ mkdir ex11_3 $ cd ex11_3 创建hwkm.c,这名字是Hello World Kernel Module首字母所写: #include <linux/module.h> #include <linux/kernel.h> #include <linux/init.h> #include <linux/proc_fs.h> struct proc_dir_entry *proc_file;(proc文件结构体指针) ssize_t proc_file_read(struct file *file, char __user *ubuf,size_t count, loff_t *ppos) {(读取回调函数,当用户空间读取/proc/hwkm文件) int copied = 0; if (*ppos > 0) { return 0; } copied = sprintf(ubuf, "Hello World From Kernel Module!\n"); *ppos = copied; return copied; } static const struct file_operations proc_file_fops = { .owner = THIS_MODULE, .read = proc_file_read }; static int __init hwkm_init(void) {(模块初始化回调) proc_file = proc_create("hwkm", 0, NULL, &proc_file_fops);(在/proc目录创建hwkm文件) if (!proc_file) { return -ENOMEM; } printk("Hello World module is loaded.\n"); return 0; } static void __exit hkwm_exit(void) {(模块退出回调) proc_remove(proc_file);(移除proc文件) printk("Goodbye World!\n"); } module_init(hwkm_init);(定义模块回调,注册模块初始化) module_exit(hkwm_exit)(退出回调,卸载使用) /proc/hwkm文件为用户空间与内裤模块通信点。内核模块中代码可以访问几乎所有的内核,可能泄露信息给用户空间,这是主要安全问题,需要进一步学习编写安全内核模块。 为了编译前面代码,需要使用合适的编译器,包括链接合适的库。我们采用Makefile文件构建内核模块。Makefile文件: obj-m += hwkm.o all: make -C /lib/modules/$(shell uname -r)/build M=$(PWD) modules clean: make -C /lib/modules/$(shell uname -r)/build M=$(PWD) clean 编译,创建a .ko文件(.ko扩展名表示内核对象文件,有点像共享库,可动态加载): $ make(有警告,没有相关lisense) $ ls -l(查看生成的内核模块) $ sudo insmod hwkm.ko(Linux中使用insmod命令加载安装内核模块,/proc/hwkm文件被创建) $ dmesg(查看最新内核日志) $ cat /proc/hwkm(用cat命令标准输出打印,从内核模块拷贝到用户空间) $ sudo rmmod hwkm(使用rmmod卸载模块) 内核模块特征:内核模块可以加载和卸载无需重启,当加载时变成内核一部分可以访问内核中任何单元或结构体(这也是最容易受到攻击的,Linux内核避免安装未知模块),只需要编译内核模块源代码。
第12章:最新c语言 改变是不可避免,C语言也不例外。C编程语言为ISO标准,为了变得更好用和新增特性需要定期修订。这不是说使用起来就一定更容易,尽管如此,新增内容出现全新复杂特性。本章介绍C11特性,已经被C18取代了。有趣的事,C18没有比C11提供任何新的特性,它仅仅是修复C11中发现的bug。本章将简要介绍如何检测C版本,如何写兼容各种C版本代码,用新特性优化编写安全代码(无返回函数和边界检查函数),新数据类型和内存对齐技术,泛型函数,C11统一编码支持,匿名结构体和联合体,C11中多线程同步技术。 C11:C语言代码由百万行之多,如果要增加新的特性,必须保证与前面代码或特征兼容。新特性不应该给已存在的程序带来新问题,不应该由bug,这是比较理想,但我们应该致力于此。http://www.open-std.org/JTC1/SC22/wg14/www/docs/ n1250.pdf C17与C18是相同C标准,C18被C17非正式引用,C17缺陷在C18中解决。 查找C标准支持版本:gcc和clang(macOS系统)都完美支持C11,当然也可以切换到C99甚至更早的版本。支持不同C版本编译器能识别当前使用C版本。gcc4.7支持C11。 定义宏探测C版本: #include <stdio.h> int main(int argc, char** argv) { #if __STDC_VERSION__ >= 201710L printf("Hello World from C18!\n"); #elif __STDC_VERSION__ >= 201112L printf("Hello World from C11!\n"); #elif __STDC_VERSION__ >= 199901L printf("Hello World from C99!\n");(c99和更早的版本不支持//注释,只支持/**/d多行注释) #else printf("Hello World from C89/C90!\n"); #endif return 0; } 通过给c编译器传递- std=CXX候选参数。 $ gcc ExtremeC_examples_chapter12_1.c -o ex12_1.out(默认C11) $ ./ex12_1.out $ gcc ExtremeC_examples_chapter12_1.c -o ex12_1.out -std=c11 $ gcc ExtremeC_examples_chapter12_1.c -o ex12_1.out -std=c99 $ gcc ExtremeC_examples_chapter12_1.c -o ex12_1.out -std=c90 $ gcc ExtremeC_examples_chapter12_1.c -o ex12_1.out -std=c89 移除gets函数:get函数容易受到缓存溢出攻击(缺少边界检查),推荐不使用,C11标准就直接移除,fgets函数用于替代gets。 fopen函数变化:打开文件返回文件描述,文件不一定位于文件系统中。 FILE* fopen(const char *pathname, const char *mode);(mode字符串表示文件打开模式,w,r,a,w+) FILE* fdopen(int fd, const char *mode); FILE* freopen(const char *pathname, const char *mode, FILE *stream); $ man 3 fopen(FreeBSD手册查看fopen 打开模式) C11中fopen_s API为fopen安全版本,https://en.cppreference.com/w/c/io/fopen ,对缓存额外检查和边界检查。 边界检查函数:C程序操作字符串和字节数组是容易越界,利用缓存溢出攻击导致DSO拒绝服务,此种攻击始于操作字符或字节数组。string.h头中strcpy和strcat由于缺少边界检查脆弱的函数。C11中引入后缀_s新的函数集,支持安全功能实现,支持运行时边界检查,如strcpy_s和strcat_s。 errno_t strcpy_s(char *restrict dest, rsize_t destsz, const char *restrict src);(确保源字符串比目的字符串短)。 无返回函数:有时候函数调用故意做成没有返回,需要知道函数没有返回编译器可以更好的优化。 void main_loop() { while (1) { ... } } int main(int argc, char** argv) { ... main_loop(); return 0; } C11中使用_Noreturn(stdnoreturn.h头文件中定义)标记为无返回函数。编译器知道无返回函数,跳跃,如果由返回将产生逻辑bug警告。 _Noreturn void main_loop() { while (true) { ... } 泛型宏:c11引入_Generic关键字,用于编译时确定宏。一起非C标准,C11写入标准。 #define abs(x) _Generic((x), int: absi, double: absd)(x)(基于参数x类型使用不同表达式) 统一字符编码Unicode:c11执行UTF-8, UTF-16, and UTF-32编码。之前C程序员必须使用第三方库如ICU实现。C11之前,只有char和unsigned char类型 ,8位变量存储ASCII(128个字符)和扩展ASCII字符(256个字符)。超过1字节存储字符为宽字符。UTF-8第一个字节存储一般ASCII字符,其他字节最多到4字节存储另一半ASCII字符和其他宽字符,UTF-8被认为是可变编码。UTF-16使用1个或2个字(每个字16位)存储所有字符,因此也是可变编码。UTF-32采用固定字节书,需要更多内存空间存储字符串。UTF-8和UTF-16被认为是压缩编码。 #include <stdlib.h> #include <stdio.h> #include <string.h> #ifdef __APPLE__(苹果系统) #include <stdint.h> typedef uint16_t char16_t; typedef uint32_t char32_t; #else #include <uchar.h> (utf-16和utf-32必须) #endif 由于C11中没有提供操作统一编码字符串,必须写一个熄灯呢strlen含返回字符数,字节数。 typedef struct { long num_chars; long num_bytes; } unicode_len_t; unicode_len_t strlen_ascii(char* str) unicode_len_t strlen_u8(char* str) unicode_len_t strlen_u16(char16_t* str) unicode_len_t strlen_u32(char32_t* str) 采用u""表示utf-8,u“”表示uft-16,U""表示utf-32。UTF-16更好平衡字符数与字节数,因为大多数字符都是两字节,utf-8很少使用。 匿名结构与匿名联合体:没有名字,用于嵌套类型。 typedef struct { union {()匿名联合体) struct { int x; int y; }; int data[2]; }; } point_t; 多线程:POSIX线程函数很早之前就支持多线程,pthreads库,Window系统中必须使用系统提供的库。而C11引入标准线程库,所有系统中用C标准均可实现。由于写本书的时候C11线程在Linxu和macOS系统中实现,所以没有给出例子。 C18有关:C18标准仅修复C11bug,没有新的特性引入。http://www.open-std.org/jtc1/sc22/wg14/www/docs/n2244.htm .
第13章:并发 本章及后面一章,讨论并发及并发编程原理,不仅适用与C语言,对其他语言也是如此。本章讨论并发编程基本概念,第14章讨论同步机制,为处理多线程和多进程准备。本章基于POSIX线程库,理解:并行系统与并发系统如何区别,何时需要并发,什么是任务调度,有哪些广泛使用的调度算法,并行程序如何运行,何为交叉存取,什么是共享状态与各种任务如何访问它。 并发介绍:并发简单意思就是程序中多逻辑段同时执行。现代软件系统通常都是并发的,程序需要多逻辑同时运行,因此并发是今天每个程序一定程度上使用的东西。并发为编写同时管理不同任务程序的强有力工具,通常由内核支持。同时管理多任务程序有很多,如浏览器,一遍下载以便浏览内容,另一个是视频流应该,以边缓存一边观看。即便是最简单的文本处理软件,一边写一边执行拼写检查。 同时运行如此多程序看起来很炫,但是正如大多数技术一样,并发带来好处的同时也有一些麻烦可能长时间因此,甚至发行几个月后才显现,难以发现、复现和解决。 并发虽说是同时运行,但跟并行不大一样。简单理解,并发指流水线方式执行多任务,虽说多任务同时执行,但从硬件执行角度来说本质上还是串行顺序执行的,而并行一般指硬件上并行,是真正的同时运行,具体比较参考官方文献。 并行:并发系统,需要暂停一个任务来让另一个任务执行,并行指同时执行任务。日常生活中并行例子由你和你朋友同时做两个分离的任务。为了多任务并行执行,我们需要分离隔离处理单元,每个分配给定的任务,如每个cup核心同一时间只能处理一个任务。如果需要并行执行多个任务,就需要至少2个独立的处理单元,现代cpu一般都有多个核心,如4核cpu有4个处理单元。 并行化指任务并行执行,即算法可以划分到多个处理单元执行。但是,今天我们写的大多数算法都是顺序的而非并行,即便是多线程,也是每个线程由一定数量不能分解并行执行流的顺序指令。也就是说,顺序算法不能被操作系统自动分解成并行执行,即便由多核cpu,你仍需要给每个cpu核心分配执行流。如果你有多个流分配,也不能并行运行,你看到的是并发行为。当然,将两个执行流分配到不同核心,得到刘哥哥并行流,但分配到一个核心,得到的是两个并发流。在多核cpu,既有多核并行,也有单核并发。 对于计算机架构而言,并发是复杂和难以讲清的,也是从并发分离出来的学术方向。 并发:并发与多任务思想相同。当你的系统同时管理多任务,不一定意味着任务并行执行,因为可能是中间的任务调度,对任进行务快速切换执行。这在单处理器单元肯定如此。如果任务调度器足够快分配公正,你是看不到任务间切换,更像并行执行。Multics是第一个多任务操系统,同时管理多个进程。 任务调度单元:内核中由任务调度单元或简单调度单元,无缝并发执行任务。 调度器有一个等待执行的任务队列,任务或工作分段执行;队列由优先级,高优先级首先被选择执行;任务调度器在所有任务中管理共享处理单元;有很多任务调度算法可以让任务调度器使用,但他们都应该是公平的,没有任务永远不被执行;根据选择的调度策略,调度器应该分配时间片或时间量给任务使用处理单元,另外调度必须等待任务释放处理器单元;如果调度策略为抢占式,调度器应该能从运行任务中强制收回cpu核心给下一个任务;抢占式调度算法应该共享时间片并对不同任务公平。任务是任务片段抽象概念,在并发系统中执行,不一定是计算机系统,CPU不是唯一共享类型资源。 进程与线程:操作系统中,任务既可以以进程执行也可以以线程执行,对于大多数操作系统而言,一些任务需要并发执行是一样的。一个操作系统需要使用任务调度器在进程或线程任务间共享cpu核心,新任务金额如调度队列,开始运行前等待获得cpu核心。在分时或抢占式调度器,如果任务在一定时间内不能完成,cpu核心将被任务调度器抢占收回,任务重新进入队列。任何时候抢占式调度在停止运行进程并将其他进程置于运行状态,这就是上下文切换,上下文切换越快,用户越觉得任务是并行运行。有趣的是,今天大多数操作系统都是抢占式调度。 任务运行时,需要成百上千上下文切换才能完成,然而上下文切换有个及其独特特征,不可预测。即便是两个相同平台下非常接近连续运行的程序,上下文切换顺序也不同。这产生的重要影响并没夸大,上下文切换不可预测。处理这些问题时候,假设所有指令切换概率相同。换言之,你不能期望任何给定运行按照经验进行上下文切换。 发生前约束:虽说上下文切换不可预测,但可确定是并行执行。 下面指令是有顺序的,也就是所相邻两个指令是有顺序约束的。我们不知道何时上下文切换,但是任何指令间都可能发生。 Task P { 1. num +5 2. num++ 3.num =num -2 4. x =10 5. num = num + x } 不管上下文切换次数和位置如何,但都遵守确定的发生前约束(前一个操作对后一个操作可见),这就决定整个任务的行为,整个任务状态保持一致。所谓任务的总体状态,是指执行任务中最后一条指令后的变量集及其对应的值。由于发生前约束,并发也不会影响整体任务状态。 假如所有并发任务堆共享资源都有读写权限,如果所有任务只是读共享变量,没任务写变量(改变变量值),我们可以说不管上下文切换如何发生,不管任务运行多少次,结果都相同。这对于没有共享变量的并行任务依然正确。但是,如果有一个任务改变共享变量值,那么上下文切换将影响整个任务状态,主要是影响了任务中间状态(指令处变量集及其值),也就是说每次运行结果可能不同。因此采用合适的控制机制避免不想要的结果。每个任务只有唯一一个整体状态,但是在执行一些指令后有很多中间状态。为了抵消上下文切换负面影响,需要用的合适的同步方法。 何时需要并发:单任务比多任务有更少的问题。如果你写一个程序,没有并发也是可接受的话,那么推荐你这么做。并发使用有通用模式。 一个程序,不管使用何种编程语言,指令集应当按顺序执行。也就是说,前面指令被执行,才能执行当前指令,我们称这个为顺序执行,指令是堵塞的,因此有时也叫堵塞指令。 如果堵塞指令需要太长时间,我们就需要并行编程。任何一个堵塞指令完成是消耗一定时间,最好是短一些好。我们并不知道堵塞时间会有多久,通常等到一个确定事件发生或数据可访问。 并法第一种模式就是当你有指令流阻塞未知时间,你就应当将工作流或任务分成两部分,如果你需要后面程序执行,你就不需要等待当前指令执行完成与否,这里面假设了当前后面指令不依赖与当前指令执行结束。将工作流分成两个并发任务,一个任务等待阻塞指令完成,另一个任务继续执行其他非并发指令。 例如:计算从客户端读取两个数之和返回结果给客户端,定期写入服务客户端数字到文件中,必须同时服务多个客户端。 单任务完成代码,所有指令堵塞: Calculator Server { Task T1 { 1. N = 0(非堵塞,任务1,共享变量,并发执行需要注意引起的问题) 2. Prepare Server(非堵塞,任务1,生成任务2) 3. Do Forever { 4.Wait for a client C(等到客户端连接,堵塞,5到10依赖于4,任务2,生成任务3) 5.N = N + 1(任务2) 6.Read the first number from C and store it in X(阻塞,任务3) 7.Read the second number from C and store it in Y(阻塞,任务3) 8.Z = X + Y(任务3) 9.Write Z to C(任务3) 10.Close the connection to C(任务3) 11.Write N to file(不管有没有客户端都要执行,任务1) } } } 每当执行的阻塞操作的完成时间未知或需要很长时间才能完成时,都应将该任务分为两个并发任务。如果接入客户端增加太空,任务3实例将快速增加,都在等到用户输入,导致消耗相当多资源,服务器程序将终止因为再也不能服务更多客户端了,称为拒绝服务DoS。服务器资源拥塞导致崩溃,没响应,常见于网络攻击,利用漏洞阻止服务,乃至整个网络崩溃。 如果一个或一组指令完成需要消耗很长时间,我们将任务分解到新的任务并发执行,这与第一种模式不同,我们估计完成需要的时间,虽说不准确。 共享状态:在执行并发任务时上下文切换不确定模式,与一个可修改的共享状态,可能导致整个任务状态非确定性。 状态术语使你想起特定时间变量集和对应的值。当讨论整个任务状态时,定义为最后指令执行结束所有存在的非共享变量与其对应的值。类似地,中间任务状态指的是当执行一些指令后,所有存在的非共享变量集及对应的值,最后中间状态与整体任务状态相同。共享任状态指的是在特定时间并发任务系统读写变量集及对应值。共享状态不属于任务(局部任务),它任何时候都可以被系统中运行的任何任务读写。对于只读共享状态,一般都是安全的,我们并不关心,。然而可修改的共享状态如果没保护好,通常会产生一些严重的问题。 例子,任务P与Q运行在通信cpu核心中,不同上下文切换,交叉存取,导致不同结果: Concurrent System { Shared State { X : Integer = 0 } Task P { A : Integer 1. A = X 2. A = A + 1 3. X = A 4. print X } Task Q { B : Integer 1. B = X 2. B = B + 2 3. X = B 4. print X } } 当面对不可预测的上下文切换,应该要有一个关键的约束,保持不变量,即便没有数据竞争或条件竞争,也没有内存泄露,更没有奔溃。这些约束比程序可见输出更为重要。 在实际C程序中,我们通常写成x++而非x = x+1,而非拷贝再增量再拷回来。这些并非原子操作,而是由三个小的原子操作组成。原子操作指不可再分的最小操作,不能被上下文切换中断。 P与Q两个任务与系统中其他任务交叉存取,并不会改变PQ任务中间状态,其他任务没有PQ共享状态,也就是说相同任务没有共享资源,交叉存取是没关系的。其他任务影响QP的情况就是任务太多影响PQ执行速度。 共享资源不应当要在内存中,如文件、网络服务。当共享文件被多任务访问频率可能很高,因为我们需要研究并发更深层次问题。
第14章:同步 本章将学习:并发相关的问题即条件竞争,数据竞争;使用同步访问共享状态的并发控制技术;POSIX中并发。 并发问题:一些并发问题仅存在于没有使用并发控制技术的地方(不同交叉引用导致不同整体状态),而另一中由使用并发控制技术引起的问题(当你修复一个并发问题可能导致新的问题,解决数据竞争到时任务不执行)。两中不同的并发问题:并发系统中没有控制机制(同步),叫做并发本质问题;解决并发问题导致新问题,叫做后同步问题。第一种不可避免,必须通过控制机制来处理,所有并发系统都存在。第二种是但程序员使用控制机制不恰当才发生,并非机制本身,也不是并发系统的固有属性。 并发固有问题:多任务交叉存取是并发系统固有属性。即便遵守发生前约束,这些非确定性导致不同任务执行顺序不一。交叉存取本身并不是问题,这是并发系统固有属性,只是在一些例子中,这些属性不满足一些需要遵守的约束,可以说是交叉存取引发的问题。系统不变约束被交叉存取改变,引发的问题,因此我们需要采用控制机制,有时称为同步机制来保证不变约束和不变量。有时候交叉存取很少产生问题,一百万次才发生一次,但结果可能是致命的。 每个并发系统都有一些完全定义的不变量约束,交叉存取应该满足这种定义好的不变量约束。当交叉存取破坏不变量约束,我们称为系统条件竞争。不满足不变量约束导致逻辑错误或突然崩溃。 例子,条件竞争: Concurrent System {(可能导致段错误) Shared State { char *ptr = NULL; (共享指针) } Task P { 1.1. ptr = (char*)malloc(10 * sizeof(char)); 1.2. strcpy(ptr, "Hello!");(可能崩溃) 1.3. printf("%s\n", ptr); } Task Q { 2.1. free(ptr);(先执行导致崩溃) 2.2. ptr = NULL; } } 有时候要找到条件竞争并非易事,一般竞争探测(由一组程序探测,分为静态竞争源码交叉存取检测,动态运行竞争检测)只用识别交叉存取导致的条件竞争。 对于交叉存取并没有验证遵守不变量约束才会导致问题,因为我们需要恢复以确保满足不变量约束。条件竞争不一定需要共享状态。为了避免条件竞争,我们需要确保一些执行执行顺序不变,通常是以小段指令集(临界区)。可写共享状态与指定的不变约束可以在读写指令强加一个严格顺序。一个最重要的写共享变量约束是数据完整,读取最新共享变量值,更新共享状态在修改共享状态前。 当交叉存取导致共享状态相关数据完整性约束失效,我们称为数据竞争。数据竞争与条件竞争相似,只是需要一个多任务共享状态,共享状态至少可被一个任务修改。 条件竞争并非可轻易解决,我们需要用到一些同步机制使得不同任务中指令按照给定的顺序执行,这也将可能的交叉存取按照指定顺序进行。 同步后的问题:误用控制机制,导致三个关键问题,他们有着不同的根本原因:新的内部问题,不同的条件竞争或数据竞争,控制机制加强指令间严格顺序,可能引发新的内部问题,你需要用到同步技术并根据程序逻辑调整以修复这些新的问题;饥饿,由于特定的控制机制,并发系统中任务长时间无法访问到共享资源,也称为任务处于饥饿中,依赖于这个任务的其他任务也将变成饥饿状态;死锁,并发系统所有任务都处于相互等待,没有任务往前执行,说明发生死锁,这主要是因为控制机制用错方法,导致任务进入无限循环等待其他任务释放资源或解锁对象,所有任务卡住相互等待,有时候仅有一两个任务卡住其他任务可以继续,这叫半锁;优先级倒置,使用同步机制后,高优先级任务访问共享资源堵塞到低优先级任务后面,这就是优先级倒置。 在默认的并发系统中饥饿并不存在,也就是没有同步技术强加在操作系统调度任务调度器上,系统是公正的,并不会允许任何任务处于饥饿状态。同样的,直到程序员处理之前,并发系统并不存在死锁,引起死锁的原因主要是锁使并发系统中所有任务相互等待对方释放锁。死锁相对饥饿岗位普遍。 同步技术:同步技术,并发控制技术,并发控制机制,用于解决并发相关的内部问题,如解决一部分交叉存取引发的问题问题。 每个并发系统都有自身的不变量约束,并非所有交叉存取都能保持这种约束。对于那些交叉存取无法满足系统不变量约束,我们需要发明方法在指令间强加特定顺序。换言之,我们创建新的交叉存取以满足不变量约束,来替换差的交叉存取。在使用一些同步技术后,我们获得带有新的交叉存取的新并发系统,并且我们希望新系统保持满足不变量约束,不产生后同步问题。 为了使用同步技术,我们需要写新的代码并改变现有代码。当改变现有代码时,指令顺序被改变、新的交叉存取。 新的交叉存取是如何解决我们的并发问题?通过新增的交叉存取工程,把发生前约束强加到不同任务不同指令间,从而保持满足不变量约束。 并发系统中两不同任务的两条指令并没有发生前约束,但使用了同步技术,我们定义了新的发生前耶稣管理指令执行顺序。有一个整体新的并发系统意味着有一个新的、不同的问题。最初并发系统只有任务调度执行上下文切换,后面的系统面对人工和工程化并行系统,任务调度器并非唯一作用因素。采用的并发控制机制保持系统不变量约束是另一重要的因素。 采用合适的控制技术,同步一些任务,并使他们遵守特定顺序,而不依赖最初并发环境。例如多进程程序同步控制技术与多线程程序中并不相同。 繁忙等待与自旋锁:一种通用的方法把顺序强加到任务指令执行上,一个任务指令必须在另一个任务指令之后,也就是前者等待后者。有两种实现方法,一种是前者检测后者任务是否完成,一种是后者通知前者可以执行。 例子,不变量约束先A再B: Concurrent System { Shared State {(共享状态同步) Done : Boolean = False } Task P { 1.1. print 'A' 1.2. Done = True } Task Q { 2.1. While Not Done Do Nothing(锁,一繁忙等待,浪费资源) 2.2. print 'B' } } 繁忙等待等待事件发生,虽然简单,但并非高效方法。只是等待,浪费时间片。长时间等待应避免繁忙等待,浪费cpu时间,对于一些预期等待时间短的情形可以采用。 在实际c语言编程,包含其他编程语言,锁通常用于强加一些严格顺序。锁可以是一个对象,也可以是一个变量,用于等待条件满足或事件发生。 休眠/通知机制:用休眠替代繁忙等待,这也是大多数操作系统避免繁忙等待的实际标准实现方法。 Concurrent System { Task P { 1.1. print 'A' 1.2. Notify Task Q } Task Q { 2.1. Go To Sleep Mode(如果1.2已经发生再进入休眠话,将无法再收到通知,这就是后同步问题) 2.2. print 'B' } } 任务如何休眠呢?如果任务休眠了,任务调度器知道就不会给它分配任何时间片,也不会获得任何CPU资源。任务休眠不会因为繁忙等待而浪费cpu时间。替代轮询繁忙等待,任务进入休眠,当条件满足时收到通知,这将显著提高cpu利用率,真正需要cpu资源的任务将会获得。当任务进入休眠模式,要有一种机制唤醒,这种机制通常由通知或信号完成唤醒,任务调度器将把它重放回队列中以获得cpu资源。任务休眠到通知唤醒,大多数操作系统尤其是POSIX兼容的系统,都有特定的系统调用方法实现。 为了避免后同步问题,需要布尔型标记,但在多核cpu运行可能会导致半锁: Concurrent System { Shared State { Done : Boolean = False } Task P { 1.1. print 'A' 1.2. Done = True(cpu核局部缓存中,不一定写回或在其他cpu核局部缓存中更新,可能导致Q永远休眠下去,Memory Barrier) 1.3. Notify Task Q } Task Q { 2.1. While Not Done {(Memory Barrier) 2.2.Go To Sleep Mode If Done is False (Atomic)(必须为原子操作,否者依然无法解决) 2.3. } 2.4. print 'B' } } 当我们定义清楚的临界区,使用互斥量包含,这才是真正意义上的同步。使用循环是因为可以由系统中任何通知唤醒任务。在实时系统中,操作系统和其他任务可以通知任务。当任务被通知哈唤醒,应当再次检查标记,如果标记没被置位,任务回到休眠状态。这在多核cpu中可能发生半锁。 基于等待/通知机制通常使用条件变量实现,与POSIX API对应。所有同步机制有某种等待,这是你保持多线程同步的唯一方式,一些等待,一些继续,这就需要信号量。 信号量与互斥量:上世纪60年代,荷兰著名计算机与数学科学家Edsger Dijkstra,与他同事一起设计了名为THE多程序系统(THE OS),用于自代唯一架构的X8计算机。此后不到十年,Unix问世,接着是C语言。THE OS是用汇编语言写的,是一个有多级架构多任务操作系统。最高层是用户层,最底层是任务调度层。用Unix术语来说,最底层等价于内核环中任务调度器和进程管理单元。Dijkstra和他的团队发明的方法解决了一些并发相关的难题,在不同任务间共享不同资源,举止信号量概念。 信号量为用于同步访问共享资源的简单变量或对象。下面将详细接着一种特别信号量类型,广泛用于并发编程的互斥量。 任务即将范围共享资源(简单变量或共享文件)时,应该先检查预定义的信号,请求继续与访问共享资源权限。例如,医生看诊,医生为共享资源,可以被很多病人访问,犹如任务想访问共享资源一样,而医生秘书(护士叫号管理)是信号量,秘书有个列表,每个信号量都有一个待处理任务队列(等待访问共享资源),医生的房间可以当作临界区。 临界区是一个被信号量保护的简单指令集,没有在信号量后等待,任务是不能进入的,也就是说信号量的工作就是保护临界区。任何时候,任务试图进入临界区,应该先让信号量知道。类似的,任务完成并想退出临界区,也是需要告诉信号量的。需要注意的是,临界区应该满足一些条件。 例子: Concurrent System { Shared State { S : Semaphore which allows only 1 task at a time(共享信号量,同一时间只允许有一个任务进入) Counter: Integer = 0 } Task P { A : Local Integer 1.1. EnterCriticalSection(S)(进入临界区,Lock(S)) 1.2. A = Counter 1.3. A = A + 1 1.4. Counter = A 1.5. LeaveCriticalSection(S)(退出临界区,UnLock(S)) } Task Q { B : Local Integer 2.1. EnterCriticalSection(S)(进入临界区,Lock(S)) 2.2. B = Counter 2.3. B = B + 2 2.4. Counter = B 2.5. LeaveCriticalSection(S)(退出临界区,UnLock(S)) } } 进入临界区,由多种实现方式,简单的就是繁忙等待或进入休眠模式,后者比较常见,等待进入临界区的时候进入休眠模式。 信号量可以同时运行由多个任务进入(取决于信号量创建时定义)临界区。当信号量只允许一个任务进入临界区,称为二元信号量或互斥量。互斥量比信号量更为常见,经常可以在并发代码中找到。POSIX API既有信号量也有互斥量,具体应用需根据使用场景而定。 互斥量代表相互排斥。假如我们有两个任务,每个任务都有一个访问相同共享资源的临界区。基于相互排斥,不受竞争条件限制,相关任务满足以下条件:任何任务进入临界区,直到退出前其他任务都要等待;不死锁,等待临界区的任务最后应该可以进入,有时设定等待时间上限(竞争时间);已进入临界区任务不能被其他想进临界区任务抢占退出临界区。临界区也要满足类似条件:同一时间只允许一个任务进入,不会死锁。信号量也要满足后面两个条件:不死锁,不抢占退出,可以运行多个任务同时进入。 相互排斥是并发里面最为重要的概念,在各种控制机制里面有重要分量。你所见过的每个同步技术里面都相互排斥的足迹(使用信号量与互斥量,尤其是互斥量)。信号量与互斥量可以说成加锁对象,正式的术语,等待信号量,进入临界区跟信号量加锁一样,类似地,离开临界区,更新信号量与信号量解锁等同。 因此,信号量加锁和解锁可以当成等待与获取访问临界区、释放临界区的两个算法。例如自旋锁,通过繁忙等待信号量请求访问临界区,当然也有其他类型加锁与解锁算法。 通过信号量等待获取锁称为争夺,任务越多争夺越多,争夺时间是衡量任务执行缓慢程度。虽说并发系统中争夺时间为非功能需求,但也应该小心以防任何性能下降。 当需要等待满足特定条件时,除了互斥量,条件变量也扮演了重要角色。 多处理器单元:当只有单处理器单元,cpu只有一个核,任务访问指定主存地址,读取最新值,即便地址被缓存到cpu核中。主存地址缓存到cpu核中,作为局部缓存,同时保持变化,降低主存读写vis可以提供性能。一些事件中,cpu核将局部缓存中变换传会主存,以保持两者同步。多个处理核中局部缓存依然存在。注意,也不是所有cpu都有自己的局部缓存。 当两个不同任务运行在不同cpu核上,需要访问相同主存中地址,每个核把对应的主存地址缓存到自己局部缓存中。也就是说,其中一个任务写共享内存地址,改变的其实只是局部缓存,而不是主存和另一个cpu核中的局部缓存。这个问题由于不同cpu核局部缓存引起的,可以用cpu核间内存一致性协议解决。通过一致性协议,当其中其一个cpu核中值改变,运行在其他cpu核中任务该值保持一致。也就是说,处理器间内存地址相互可见。多处理单元运行并发系统,缓存一致与内存可见是两个重要的方面。 内存屏障或内存栅栏Memory Barrier,使得局部缓存更新传播到主存和其他局部缓存。穿件任务、信号量上锁,信号量解锁三个操作与内存屏障和cup核局部缓存与主存同步一致,将共享状态最新改变传播出去。 任务给互斥量(信号量)加锁,以下情况将自动解锁:任务使用Unlock命令解锁互斥量;任务执行解锁,所有锁住的互斥量将解锁;任务进入休眠模式,锁住的互斥量解锁(使用时需注意这条)。 互斥量是不能重复加锁,如果重复加锁可能导致死锁发生。只有递归互斥量可以多次加锁。加锁和解锁应成对使用。 自旋锁:简单繁忙等待算法,当你给自旋锁互斥量加锁时,将进入繁忙循环等待直到获得互斥量。 Concurrent System { Shared State { Done : Boolean = False M : Mutex } Task P { 1.1. print 'A' 1.2.SpinLock(M) 1.2.Done = True 1.3.SpinUnlock(M) } Task Q { 2.1 SpinLock(M) 2.2.While Not Done { 2.3. SpinUnlock(M) 2.4. SpinLock(M) 2.5. } 2.6.SpinUnlock(M) 2.7.print 'B' } } 对于高性能系统,相比于系统偶发事件,将任务置于休眠模式是非常昂贵的,因此自旋锁就很常用。使用自旋锁,任务尽可能快地被解锁互斥量,也就是临界区应该尽量小。 条件变量:条件变量为简单变量或对象,用于将任务置于休眠模式(不同休眠多少毫秒,延迟)或唤醒休眠的任务,通过使用不同任务间信号。条件变量有休眠和通知操作,这两叫法因编程语言而异,有些可能是等待和信号,但背后的逻辑是一样的。条件变量必须与互斥量一起使用,如果使用条件变量而没有用互斥量就缺少相互排斥属性。记住,条件变量必须在多任务间共享(作为共享资源是有益的,需要同步访问它,经常通过互斥量守护临界区。 例子,条件变量和会持有等待满足条件,更为一般等待共享标记完成: Concurrent System { Shared State { Done : Boolean = False CV : Condition Variable M : Mutex } Task P { 1.1. print 'A' 1.2. Lock(M) 1.3. Done = True 1.4. Notify(CV)(使用条件变量,一定要在加锁内,否者产生竞争条件) 1.5 Unlock(M) } Task Q { 2.1. Lock(M) 2.2. While Not Done { 2.3. Sleep(M, CV)(使用条件变量) 2.4. } 2.5. Unlock(M) 2.6. print 'B' } } 在并发系统中使用条件变量实现两条指令严格按照顺序执行。互斥量对象与条件变量一起充当监视对象,重排并发指令。 POSIX中的并发:并发或多任务是操作系统内核中的功能。并发诞生之初,虽不是所有内核都支持,但现在都支持了。并发作为标准的一部分已经很长时间,允许开发者给POSIX兼容的操作系统写并发程序。POSIX支持的并发已经众多数操作系统实现,如Linux和macOS。 POSIX兼容的操作系统中并发通常提供两种方式,既可以在不同进程中执行并发(多进程),也可以在同一进程多线程中执行(多线程)。 内核支持的并发:几乎所由开发维护的内核都支持多任务,正如我们了解的,每个内核都有一个任务调度单元,多进程间共享cpu核心,线程充当任务。继续之前,我们先介绍并发中进程与线程差异。进程在运行任何程序是本创建,程序逻辑运行在进程中,进程间相互隔离,一个进程不能访问另一个进程内部如内存;线程与进程很相似,只是线程是进程局部,通过多线程执行实现进程并发。两个进程间不能共享线程,因为线程属于所属进程局部对象。同一进程中所有线程可以访问进程共享内存,每个线程又有自己的栈区,也可被同一进程中其他线程访问。另外,进程和线程都可以使用cpu共享,大多数内核任务调度器均使用相同调度算法共享cpu核心。 当从内核层讨论时,我们使用任务描述,而不是线程或进程。从内核角度,任务队列等待cup核执行指令,任务调度器单元的职责就是向这些任务无差别地提供服务。unix类内核,使用任务表示进程和线程,实际上,进程或线程是站在用户角度表示,并不是内核术语。 调度算法分为两大类:协同调度,抢占式调度。协同调度是对CPU核心分派任务并等待任务协同,释放CPU核心。在大多数正常情况下,与抢占有着本质不同,不会强行从任务中夺回cpu核心资源。抢占式表示高优先级给调度器发送信号抢占cpu核心,另外,系统中调度器和所有任务应该等待活动任务私服cpu核心。现代系统很少用协同调度,只在非常特殊的应用中使用,如实时处理。早期macOS和windows版本采用协同调度,但现在都使用抢占式调度方法。 抢占调度允许任务使用cpu核心知道被调度器取回,一些特殊的抢占调度还允许任务任务使用cpu核心确定长度时间,即时间共享,这是当今内核中使用最多的调到策略,具体时间间隔称为时间片,时间槽,量子。时间共享有很多算法,最广泛的莫过于轮询,允许公平无饥饿访问cpu核心。除了轮询,还有多优先级策略。 并发实现由两种方式:多任务环境中多进程执行并行任务,单进程中将任务分解成并行流多线程执行。大型软件项目中结合两者,十分常见。 多进程:使用多个进程完成并发任务。如web服务通用网关接口CGI标准,为每个HTTP请求启动新的解释器进程,以同时服务多个请求。CGI中进程间无需相同通信或共享数据。但有些并发任务需要多进程共享部分信息,如Hadoop基础架构集群运行。又如多节点分布式系统,Gluster、Kafka、网络加密,运行都需要不同节点进程间大量信息交换和消息传递。进程与线程,如果没有共享状态,他们是没差别的,也就是说两者主要区别在于共享状态同步技术,另一个差别就是共享状态使用。虽说线程可以使用所有进程的技术,奢侈之处在于线程使用相同内存区来共享状态。进程由一个私有内存,其他进程是不能读写的,因此使用进程内存来共享是不大容易的。这对于线程就简单多了,进程中所有线性均可范围所在进程内存。 进程间访问共享状态方式有:文件系统,最简单方式,通过配置文件,需要考虑同步问题;内存映射文件,硬盘文件映射区(POSIX类或Windows系统均有);网络,网络基础架构通信,socket编程api(SUS和POSIX标准,几乎存在与所有操作系统);信号,运行在相同操作系统中进程可以发生信号;共享内存,POSIX兼容的操作系统或Window,进程间可有由一个内存共享区,可存储和共享值;管道,一种通信信道,有名或匿名管道;Unix套接字,运行在相同机器中可使用,与网络sockets相似,但是通过内核来传递数据;消息队列,内核维护的消息队列;环境变量。 多线程:在并行环境中,使用多线程执行并行任务。几乎每个程序都使用多线程。线程不能鼓励存在,必须由所属进程,而每个进程至少有一个在线程,也叫主线程。只有一个线程的的程序叫单线程程序。进程中所有线程都可以范围想听的内存区,不必需要复杂方案来共享数据。线程与进非常相似,也就是说所欲进程用到技术都可以在线程中使用。 每个线程也有自己的栈内存,可以作为保存共享状态额占位符。线程可以为其他线程提供一个指向内部栈地址的指针,以便其他线程可以访问,这是因为多有内存地址都属于进程栈区。线程也可以访问所属进程的堆空间,使用占位符存储共享状态。 最后需要注意的是,Windows 并不支持POSIX线程API(pthreads),Windows有一套自己的API,也就Win32本地库。
第15章:线程执行 本章深度介绍线程和创建管理线程的API,探索单进程中如何完美地执行多线程:介绍线程,用户线程和内核线程,线程的属性;使用POSIX线程编程,又叫pthread,非POSIX兼容系统如windows由着自己一套线程和进程库;pthread库条件竞争和数据紧张c例子。本章仅介绍POSIX库基本使用,如果想深入了解更多更具吸引的内容,推荐花时间写例程更好地掌握。 线程:每个线程由进程发起,永远属于该进程。线程不能共享,所有权也不能转移。每个进程至少有一个主线程,C程序中main函数作为主线程执行。所有线程共享相同额的进程ID(PID),使用top或htop查看线程有着相同PID。线程继承所属进程,组ID,用户ID,当前工作目录,信号处理。例如当前线程工作目录与所属进程一致。 每个线程有唯一的线程ID(TID),用于给调试跟踪线程传递信号。POSIX线程可用个pthread_t变量访问线程ID。另外,每个线程都有专属的信号屏蔽,用于过滤出接收的信号。同一进程中所有线程杜克访问进程中其他线程打开的文件描述,因而所有线程都可以读写文件描述资源,这对于Socket描述和打开的sockets都适用。 共享位置(如数据库)具有的共享状态不同于网络传输,分属两类IPC技术。POSIX兼容系统中,线程实现共享或转移状态方法由:所属进程内存(数据,栈,堆段),适应与线程,不适用于进程;文件系统;内存映射文件;网络(使用网络sockets);线程间信号传递;共享内存;POSIX管道;Unix专属sockets;POSIX消息队列;环境变量。为了保持线程属性,同一进程中所有线程都可使用进程内存空间存储和维护共享状态,这是最常见的多线程共享状态方式,同时使用进程堆段实现。线程声明周期依赖于所属进程,当进程被杀死或终止时,进程中所有线程将终结。但主线程结束,进程立刻退出。当由其他分离的线程还在运行,进程结束前需要一直等待它们完成。内核进程创建的线程,叫内核级线程或简称内核线程,用户空间创建的进程创建的线程,叫用户级线程。内核线程比用户线程优先级高,通常用于执行重要逻辑,如设备驱动使用内核线程等待硬件信号。与用户线程可以访问相同内存区域相似,内核线程也可以访问内核内存空间,及后面的所有程序和单元。本书主要讲述用户线程,而非内核线程,因为用户线程需要的API由POSIX标准,而内心线程没有。 用户不能直接创建线程,而是首先要创建进程,而后才是进程主线程初始化其他线程。需要主要的是,只有线程才能创建线程l。 关于线程内存布局,每个线程都有自己的栈内存区,当作线程专属私有的内存区。尽管如此,当有指针指向私有地址时,可以被其他线程(同属一个进程)可访问。同一进程中所有线程栈区都是进程内存空间的一部分,也就是说可以被进程中任何线程访问。 对于线程同步,与进程同步控制机制相同,如信号量、互斥量、条件变量。当线程同步而没有数据竞争或竞争条件发生,程序通常被认为是线程安全。类似,库或函数集,可以轻易用在多线程程序中而不会引起任何新的并发问题,称为线程安全库。 POSIX线程:pthread库为POSIX兼容的操作系统中创建和管理线程的主要API。希望C11有一个统一线程API,不管是在POSIX系统还是在非POSIX系统中,但这还是太过理想,在各种C标准实现中并不存在,如glibc。pthread库由简单的头文件集和函数组成,用于POSIXj兼容的操作系统中编写多线程程序。对于pthread库,每个操作系统都有自己的实现,相互之间可能不同,但api接口是一样的。最著名的例子就是本地POSIX线程库(NPTL),linux操作系统pthread的主要实现。pthread api所有的线程函数都可以从包含头文件pthread.h获得,也可以在一些pthread扩展库中获得,仅当包含semaphore.h头文件时。 pthread线程库由以功能函数:线程管理,线程创建,合并线程,分离线程;互斥量;信号量;条件变量;各种类型锁如自旋锁和递归锁。所有pthread含都有pthread_前缀,但信号量例外(以sem_前缀),因为信号量并非原始POSIX线程库一部分,只是后面扩展时加入。 生成POSIX线程:创建线函数pthread_create和合并线程函数pthread_join。 例子,main函数只是主线程部分逻辑,main函数之前或之后还有其他代码需要执行: #include <pthread.h>(pthread标准头文件) void* thread_body(void* arg)(){...}(作为分离线程单独运行的逻辑,线程体) pthread_t thread;(线程操纵器) int result = pthread_create(&thread, NULL, thread_body, NULL);(创建新的线程,创建失败,返回true,第二个参数决定线程属性栈大小,栈地址,配置分离状体,null为默认,第三个参数为函数指针,第四个最后参数为输入参数arg) result = pthread_join(thread, NULL);(等待线程结束,返回true线程未结束) $ gcc ExtremeC_examples_chapter15_1.c -o ex15_1.out -lpthread(-lpthread编译选项参数,链接pthread库,向macOS默认链接无需参数,但还是强烈建议加上,避免任何平台成功构建并避免交叉兼容问题) $ ./ex15_1.out 所有pthread函数,包括pthread_create,执行成功返回0。ptrhead_create创建线程并不代表逻辑执行,而取决于调度何时分配cpu核心。每个进程由一个线程开始,主线程,除了进程所有的主线程,其他所有线程都有一个父线程。默认情形,主线程结束,进程也将结束,进程结束,所有其他运行或休眠的线程理解终结。因此,新线程创建之时并未开始执行,父进程结束(不管何种原因)线程也将消亡(即便还没开始执行),故而主线程需要等待其他线程执行结束后合并。主线程调用pthread_join时是阻塞的。如果主线程不合并创建的新线程,该线程根本就不能被执行。你要记住,创建线程并不表示执行。线程默认为可合并,分离线程是不能合并的。 result = pthread_detach(thread);(分离线程,让进程知道必须等到分离线程退出才能结束进程,主线程可先退出而无需父进程结束) pthread_exit(NULL);(退出主线程,必须有,让主进程知道等到其他分离线程执行完成) 如果线程没被分离,当主线程退出时进程也将结束。分离状态是线程的一个属性,创建线程前可指定,而不需调用pthread_detach函数。 竞争条件例子: void* thread_body(void* arg) { char* str = (char*)arg;(通用指针转换) printf("%s\n", str);(线程安全的) return NULL; } pthread_t thread1; int result1 = pthread_create(&thread1, NULL,thread_body, "Apple");(第四个参数传递指针,可用arg指令访问,字符串字面值存储在数据段中,非堆非栈区,地址用不会被释放,不会由野指针问题) result1 = pthread_join(thread1, NULL); 给新线程传递指针,新线程可以访问主线程访问的同一内存区,不限于特定进程内存段、区类型,如栈、堆、Text、数据段均可。一次函数调用不可能创建2个线程,因为函数调用创建栈帧位于一个线程栈顶部,两个不同的线程有不同栈区,也就是一次函数调用仅可以初始化一个线程,同一函数两次分别调用可以初始化2个线程。 传递给线程额指针参数不应该是悬挂指针(野指针),否则可能会引起严重难以追踪的内存问题。悬挂指针执行未分配内存地址,或已经被释放的内存地址。 产生野指针例子: char str1[8];(字符串数组,分配在主线程栈区中) strcpy(str1, "Apple"); int result1 = pthread_create(&thread1, NULL, thread_body, str1);(创建新线程) result1 = pthread_detach(thread1);(分离线程) pthread_exit(NULL);(分离线程,立即退出,释放主线程栈顶部分配的数组,str1字符串被释放,其他线程交叉存取这些释放的地址,即悬挂指针)。 一些约束,像不崩溃,无悬挂指针,无内存相关entire,都可以作为程序不变约束的一部分。 为了能探测悬挂指针,需要使用内存profiler,最简单方法就是多运行几次,看看程序是否崩溃,有时并总会出现,如访问野指针内容不一定会导致崩溃。为了检测不好的内存行为,可以使用valgrind。如果野指针底层内存改变,崩溃或逻辑错误才会发生,也就是说只要底层内存不变,野指针不一定会产生崩溃,因此跟踪起来会很难。 $ gcc -g ExtremeC_examples_chapter15_2_1.c -o ex15_2_1.out -lpthread $ valgrind ./ex15_2_1.out 数据竞争的例子:不变约束保护共享状态的数据完整性,加上其他显示的约束,如无崩溃、无内存访问问题等。换言之,输出如何出现无关紧要,当共享变量被其他线程改变,而写入线程并不知道最新值,该线程就不能写入新值,这就是数据完整性。 void* thread_body_1(void* arg){...}(++/+=加操作并非原子操作,修改指针指向内容) void* thread_body_2(void* arg){...} int shared_var = 0;(main中共享状态) pthread_t thread1; pthread_t thread2; int result1 = pthread_create(&thread1, NULL,thread_body_1, &shared_var); int result2 = pthread_create(&thread2, NULL,thread_body_2, &shared_var); result1 = pthread_join(thread1, NULL);(等待线程完成,主线程不会退出,&shared_var不会悬挂指针) result2 = pthread_join(thread2, NULL); $ gcc ExtremeC_examples_chapter15_3.c -o ex15_3.out -lpthread $ ./ex15_3.out 数据竞争,不会每次运行都出现,可能需要多次运行,需要有足够耐心。