1、系统内存不足
2、系统的反应速度变慢。
3、程序宕掉
在任务管理器里面查看 **“内存使用”和”虚拟内存大小”**两项,当程序请求了它所需要的内存之后,如果虚拟内存还是持续的增长的话,就说明了这个程序有内存泄漏问题(因为,当发生页面置换的时候,主存中的页会写入虚拟内存中,所以可以通过查看虚拟内存来判断)。当然如果内存泄漏的数目非常的小,用这种方法可能要过很长时间才能看的出来。
手动检查代码是否释放了无用的内存。
当我们在程序中写下 new 和 delete 时,我们实际上调用的是 C++ 语言内置的 new operator 和 delete operator。所谓语言内置就是说我们不能更改其含义,它的功能总是一致的。以 new operator 为例,它总是先分配足够的内存,而后再调用相应的类型的构造函数初始化该内存。而 delete operator 总是先调用该类型的析构函数(注意,析构函数不是用来释放对象的,而是用来释放掉对象使用的内存的。例如,一个对象利用了堆中的内存,然后我们可以在析构函数中使用delete来释放那片堆内存),而后释放内存(即释放掉对象)。我们能够施加影响力的事实上就是 new operator 和 delete operator 执行过程中分配和释放内存的方法。
new operator 为分配内存所调用的函数名字是 operator new,其通常的形式是 void * operator new(size_t size);
其返回值类型是 void*,因为这个函数返回一个未经处理(raw)的指针,未初始化的内存(即里面没有存放与对象有关的那些数据信息)。参数 size 确定分配多少内存,你能增加额外的参数重载函数 operator new,但是第一个参数类型必须是 size_t(size_t本质上是整形)。
delete operator 为释放内存所调用的函数名字是 operator delete,其通常的形式是 void operator delete(void *memoryToBeDeallocated);
它释放传入的参数所指向的一片内存区。
这里有一个问题,就是当我们调用 new operator 分配内存时,有一个 size 参数表明需要分配多大的内存。但是当调用 delete operator 时,却没有类似的参数,那么 delete operator 如何能够知道需要释放该指针指向的内存块的大小呢?答案是:对于系统自有的数据类型,语言本身就能区分内存块的大小,而对于自定义数据类型(如我们自定义的类),则 operator new 和 operator delete 之间需要互相传递信息。
当我们使用 operator new 为一个自定义类型对象分配内存时,实际上我们得到的内存要比实际对象的内存大一些,这些内存除了要存储对象数据外,还需要记录这片内存的大小,此方法称为 cookie。这一点上的实现依据不同的编译器不同。(例如 MFC 选择在所分配内存的头部存储对象实际数据,而后面的部分存储边界标志和内存大小信息。g++ 则采用在所分配内存的头 4 个自己存储相关信息,而后面的内存存储对象实际数据。)当我们使用 delete operator 进行内存释放操作时,delete operator 就可以根据这些信息正确的释放指针所指向的内存块。
以上论述的是对于单个对象的内存分配/释放,当我们为数组分配/释放内存时,虽然我们仍然使用 new operator 和 delete operator,但是其内部行为却有不同:new operator 调用了operator new 的数组版的兄弟 - operator new[],而后针对每一个数组成员调用构造函数。而 delete operator 先对每一个数组成员调用析构函数,而后调用 operator delete[] 来释放内存。需要注意的是,当我们创建或释放由自定义数据类型所构成的数组时,编译器为了能够标识出在 operator delete[] 中所需释放的内存块的大小,也使用了编译器相关的 cookie 技术。
综上所述,如果我们想检测内存泄漏,就必须对程序中的内存分配和释放情况进行记录和分析,也就是说我们需要重载 operator new/operator new[];operator delete/operator delete[]
四个全局函数,以截获我们所需检验的内存操作信息。
一句话总结:在重载的new中记录内存分配情况,在重载的delete中删除内存分配记录,从而跟踪所有内存分配信息(每次new中开辟一块内存就用链表把这个内存的信息保存下来,每次用delete删除一块内存就从链表中删除这块内存的记录。
要想检测内存泄漏,就必须对程序中的内存分配和释放情况进行记录,所能够采取的办法就是重载所有形式的operator new 和 operator delete,截获 new operator 和 delete operator 执行过程中的内存操作信息。下面列出的就是重载形式:
void* operator new( size_t nSize, char* pszFileName, int nLineNum )
void* operator new[]( size_t nSize, char* pszFileName, int nLineNum )
void operator delete( void *ptr )
void operator delete[]( void *ptr )
在重载的 operator new 函数版本中,我们将调用全局的 operator new 的相应的版本并将相应的 size_t 参数传入,而后,我们将全局 operator new 返回的指针值以及该次分配所在的文件名和行号信息记录下来,这里所采用的数据结构是一个 STL 的 map,以指针值为 key 值。当 operator delete 被调用时,我们就能以传入的指针值在 map 中找到相应的数据项并将之删除,而后调用 free 将指针所指向的内存块释放。当程序退出的时候,map 中的剩余的数据项就是我们企图检测的内存泄漏信息--已经在堆上分配但是尚未释放的分配信息。
造成内存泄漏的代码如果经常被调用的话,那么内存泄漏的数目就会越来越多,从而影响整个系统的运行。
1、分配完内存之后,忘记回收
for (int =0; I<100; I++) {
Temp = new BYTE[100];
}
2、程序的Code有问题,已经没有办法回收了
Temp1 = new BYTE[100];
Temp2 = new BYTE[100];
Temp2 = Temp1; // Temp2指针所指的内存地址就丢掉了,这个时候Temp2所指的内存空间想回收都没有办法
特别是函数内分配内存,由于疏忽,未处理或传出指针时会导致内存泄漏。
3、某些API函数操作不正确,造成内存泄漏
即有些函数可能会在堆中分配内存。
由于 C++ 语言没有自动内存回收机制,程序员每次 new 出来的内存都要手动 delete。而使用智能指针便可以有效缓解这类问题。
对于编译器来说,智能指针实际上**是一个栈对象,**智能指针是一个模板,实际上是对普通指针加了一层封装机制,并非指针类型,在栈对象生命期即将结束时,智能指针通过析构函数释放有它管理的堆内存。这样的一层封装机制的目的是为了使得智能指针可以方便的管理一个对象的生命期。
在C++中,我们知道,如果使用普通指针来创建一个指向某个对象的指针,那么在使用完这个对象之后我们需要自己删除它,例如:
ObjectType* temp_ptr = new ObjectType();
temp_ptr->foo();
delete temp_ptr;
很多材料上都会指出说如果程序员忘记在调用完temp_ptr之后删除temp_ptr,那么会造成一个悬挂指针(dangling pointer),也就是说这个指针现在指向的内存区域其内容程序员无法把握和控制,也可能非常容易造成内存泄漏。而且也可能会二次释放。
可是事实上,不止是“忘记”,在上述的这一段程序中,如果foo()在运行时抛出异常,那么temp_ptr所指向的对象仍然不会被安全删除。
所有智能指针都重载了->
操作符,直接返回对象的引用,用以操作对象。访问智能指针原来的方法则使用.
操作符。
访问智能指针包含的裸指针则可以用 get() 函数。由于智能指针是一个对象,所以:
if (my_smart_object)
永远为真,要判断智能指针的裸指针是否为空,需要这样判断:
if (my_smart_object.get())
std::auto_ptr 属于 STL,当然在 namespace std 中,包含头文件 #include<memory>
便可以使用。std::auto_ptr 能够方便的管理单个堆内存对象。
1、auto_ptr是通过权限转移的方式来防止值拷贝所带来问题,所谓权限转移就是说开辟的动态内存任何时刻只能由一个指针指向。因此这样就有些问题,控制权可以随便转换,但是只有一个在用,用起来会受到诸多限制。
因为auto_ptr不能共享所有权。尽量不要使用=
操作符把一个智能指针对象赋值给另一个智能指针对象。如果使用了,请不要再使用先前的智能指针对象,因为原来对象中的指针变成了NULL。
2、记住 release() 函数不会释放对象,仅仅归还所有权。
3、auto_ptr不能指向数组
4、auto_ptr不能作为容器的成员。
因为“STL元素必须具备拷贝构造和可赋值……”,其意思是说对象可以进行安全的赋值操作,可以将一个对象拷贝到另一个对象,从而获得两个独立的,逻辑上相同的拷贝。尤其是当一个对象被拷贝到目标对象后,原来的对象不会改变。但 auto_ptr 却不然,用 auto_ptr 进行赋值和拷贝操作不仅会改变目标拷贝,而且还明显地改变原来的对象。明确地说,就是原来对象将指针的物主身份转换成目标对象,与此同时,原来对象中的指针变成了NULL。
主要是用于对堆上面开辟的内存的管理,具体采用引用计数的机制进行。比如我们在栈上开辟了一块内存m1,并将其赋值给指针p1,那么现在m1这块内存就有一个对象在使用,引用计数为1。这时如果有另外一个指针p2也需要使用m1的内容,那么就将p2也指向m1。问题在于,如果p1使用完毕之后,使用delete语句告诉系统,这块内存我不用了,把它回收吧,那么这时p2还在指着m1的话,再次使用p2的时候就会出问题了。
然后就引入了引用计数的概念。所有的栈上的内存,在还没有被开辟的时候,该块内存的引用计数为0,在第一次用p1开辟的时候引用计数+1变成1,如果有其他指针也需要这块内存,比如一个潜copy操作,比如p2,那么这时候就有两个指针指向m1,引用计数变成2,当p1用完了,就用一个操作切断p1和m1的关系,m1的引用计数变成1。当p2也用完了,那么通过一个操作引用计数再次减去1,引用计数变成0。当智能指针这个对象发现它管理的内存引用计数变成0的时候,对m1做一个delete操作,使之释放。
作者:郑斌链接:https://www.zhihu.com/question/20368881/answer/14918243来源:知乎著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
shared_ptr内部的引用计数是线程安全的,但是对象的读取需要加锁。
#include <iostream>
#include <memory>
int main() {
{
int a = 10;
std::shared_ptr<int> ptra = std::make_shared<int>(a);
std::shared_ptr<int> ptra2(ptra); //copy
std::cout << ptra.use_count() << std::endl;
int b = 20;
int *pb = &a;
//std::shared_ptr<int> ptrb = pb; //error
std::shared_ptr<int> ptrb = std::make_shared<int>(b);
ptra2 = ptrb; //assign
pb = ptrb.get(); //获取原始指针
std::cout << ptra.use_count() << std::endl;
std::cout << ptrb.use_count() << std::endl;
}
}
1、get函数获取原始指针。
2、注意不要用一个原始指针初始化多个shared_ptr,否则会造成二次释放同一内存。
vector和数组类似,拥有一段连续的内存空间,并且起始地址不变。 因此能高效的进行随机存取,时间复杂度为O(1)。 但因为内存空间是连续的,所以在进行插入和删除操作时,会造成内存块的拷贝,时间复杂度为o(n)。 另外,当数组中内存空间不够时,会重新申请一块内存空间并进行内存拷贝。
在内存中分配一块连续的内存空间进行存储。支持不指定vector大小的存储。STL内部实现时,首先分配一个内存空间预备进行存储,即capacity()
函数返回的大小,当超过此分配的空间时再整体重新放分配一块内存存储,这给人以vector可以不指定连续内存大小的感觉。通常此默认的内存分配能完成大部分情况下的存储。
(1) 不指定一块内存大小的数组的连续存储,即可以像数组一样操作,但可以对此数组进行动态操作。通常体现在push_back()、pop_back()
(2) 随机访问方便,即支持[ ]操作符和vector.at()
(3) 节省空间。
(1) 在内部进行插入删除操作效率低。
(2)只能在vector的最后进行push和pop,不能在vector的头进行push和pop。(否则的话,会让后面的元素往前挪动,效率极低)
(3) 当动态添加的数据超过vector默认分配的大小时要进行整体的重新分配、拷贝与释放
list是由双向链表实现的。每一个结点都包括一个信息块Info、一个前驱指针Pre、一个后驱指针Post。可以不分配必须的内存大小方便的进行添加和删除操作。使用的是非连续的内存空间进行存储。 只能通过指针访问数据,所以list的随机存取非常没有效率,时间复杂度为o(n)。 但由于链表的特点,能高效地进行插入和删除。
(1) 不使用连续内存完成动态操作。
(2) 在内部方便的进行插入和删除操作
(3) 可在两端进行push、pop
(1) 不能进行内部的随机访问,即不支持[]操作符和list.at() ,只能顺序访问。(即从头节点开始,往后连续遍历)
(2) 相对于verctor占用内存多(因为需要额外的内存来存放指针)。(即需要保存指针域)
如果需要高效的随机存取,而不在乎插入和删除的效率(vector插入和删除元素需要移动内部元素的位置),使用vector; 如果需要大量的插入和删除(因为只需要改动一下指针的指向即可),而不关心随机存取,则应使用list。
低位字节排放在内存的低地址端,高位字节排放在内存的高地址端。
#include <iostream>
using namespace std;
int main(int argc, char const *argv[])
{
int i = 0x12345678;
char c = *(char*)&i; // c中存放的是低地址的数字。并且通过这种指针的转换,可以只取出变量i一个字节的数据
if (c == 0x12) {
cout<<"big end"<<endl;
}
else {
cout<<"samll end"<<endl;
}
return 0;
}
HTTPS和HTTP的区别主要如下:
1、https协议需要申请证书,一般免费证书较少,因而需要一定费用。
2、http是超文本传输协议,信息是明文传输,https则是具有安全性的ssl加密传输协议。
3、http和https使用的是完全不同的连接方式,用的端口也不一样,前者是80,后者是443。
4、http的连接很简单,是无状态的;HTTPS协议是由SSL+HTTP协议构建的可进行加密传输、身份认证的网络协议,比http协议安全。
template <typename T>
class shared_ptr {
public:
shared_ptr(T* p) : count(new int(1)), _ptr(p) {}
shared_ptr(shared_ptr<T>& other) : count(&(++*other.count)), _ptr(other._ptr) {}
T* operator->() { return _ptr; }
T& operator*() { return *_ptr; }
shared_ptr<T>& operator=(shared_ptr<T>& other)
{
++*other.count;
if (this->_ptr && 0 == --*this->count)
{
delete count;
delete _ptr;
}
this->_ptr = other._ptr;
this->count = other.count;
return *this;
}
~shared_ptr()
{
if (--*count == 0)
{
delete count;
delete _ptr;
}
}
int getRef() { return *count; }
private:
int* count;
T* _ptr;
};
1、面向连接的协议,也就是说,在收发数据前,必须和对方建立可靠的连接。逻辑通信信道是全双工的可靠信道。
2、TCP面向字节流,实际上是TCP把数据看成一连串无结构的字节流。(但是在传输的过程中,TCP还是要封装成报文段。面向字节流的意思是,TCP把数据标了序列号,对可靠性的保证都是以序列号为单位进行的。)
3、TCP保证数据顺序。(尽管在网络传输的过程中,可能会导致TCP报文段不是按照顺序到达对方传输层,但是从传输层递交给应用层一定是有序的,所以说TCP保证数据顺序。而UDP则不保证,只是负责把传输层的数据递交给应用层,而由应用层自己来排好顺序)
4、TCP充分实现了数据传输时各种控制功能,可以进行丢包的重发控制,还可以对次序乱掉的分包进行顺序控制(即第3点,TCP保证数据顺序)。
1、非连接的协议,传输数据之前源端和终端不建立连接。逻辑通信信道是不可靠信道。
2、UDP是面向报文的。
我们经常使用“ping”命令来测试两台主机之间TCP/IP通信是否正常,其实**“ping”命令的原理就是向对方主机发送UDP数据包,然后对方主机确认收到数据包,如果数据包是否到达的消息及时反馈回来,那么网络就是通的**。
3、一台服务机可同时向多个客户机传输相同的消息。
4、吞吐量不受拥挤控制算法的调节,只受应用软件生成数据的速率等的限制。
UDP不提供复杂的控制机制,利用IP提供面向无连接的通信服务。并且它是将应用程序发来的数据在收到的那一刻,立刻按照原样发送到网络上的一种机制。
即使是出现网络拥堵的情况下,UDP也无法进行流量控制等避免网络拥塞的行为。此外,传输途中如果出现了丢包,UDP也不负责重发。甚至当出现包的到达顺序乱掉时也没有纠正的功能。如果需要这些细节控制,那么不得不交给由采用UDP的应用程序去处理。换句话说,UDP将部分控制转移到应用程序去处理,自己却只提供作为传输层协议的最基本功能。
5、UDP不保证数据顺序。
6、UDP主要用于那些对高速传输和实时性有较高要求的通信或广播通信。
指相同对象收到不同消息或不同对象收到相同消息时产生不同的实现动作。
C++支持两种多态性:编译时多态性,运行时多态性。
对于虚函数来说,父类和子类都有各自的版本。由多态方式调用的时候动态绑定。
在相同作用域内,函数名称相同,参数列表不同的相关函数称为重载。
派生类重写基类中同名同参数同返回值的函数(通常是虚函数,这是推荐的做法)。
首先,信号和信号量是两个完全不同的概念。
信号是进程间传递消息,如常见的SEGV,KILL等。
信号量则用于临界资源访问的保护。
各种锁,有些是由硬件的原子操作进行保护,有些则是操作系统级别的封装,但都是为了实现对临界资源的访问控制。
#include <iostream>
using namespace std;
int main(int argc, char const *argv[])
{
int a;
cout<<"input a int num"<<endl;
cin >> a;
int n = 1;
int count = 0;
while (n <= a) {
if ((n & a) != 0) {
count++;
}
n = n << 1;
}
cout << count << endl;
return 0;
}
这种方法速度比较快,其运算次数与输入n的大小无关,只与n中1的个数有关。如果n的二进制表示中有k个1,那么这个方法只需要循环k次即可。其原理是不断清除n的二进制表示中最右边的1,同时累加计数器,直至n为0:
#include <iostream>
using namespace std;
int main(int argc, char const *argv[])
{
int n;
cout<<"input int a"<<endl;
cin>>n;
int count = 0;
while (n > 0) {
n = n & (n - 1);
count++;
}
cout<<count<<endl;
return 0;
}
其中:
n = n & (n - 1);
是用来消掉n中最低位的那个1。因此,n里面有几个1,就会循环几次。
unsigned int strlen (char *s) // 所以说,只要向strlen()中传入一个指向字符的指针即可
strlen
所做的仅仅是一个计数器的工作,它从内存的某个位置(可以是字符串开头,中间某个位置,甚至是某个不确定的内存区域)开始扫描,**直到碰到第一个字符串结束符'\0'**为止(注意:\0
不计入总数),然后返回计数器值(长度不包含'\0'
)。
所以,只需记住:strlen一定要找到'\0'
才会结束计数。
C语言中判断数据类型或者表达式长度符;不是一个函数,字节数的计算在程序编译时进行,而不是在程序执行的过程中才计算出来!sizeof 在编译时候就可以确定其返回值。
注意:如果是:
sizeof(指针); // 例如:sizeof(char*)、sizeof(int*)、sizeof(double*)
那么结果就要看情况了(看计算机地址总线的位数),因为指针就是地址呀,所以里面放的都是地址,而地址的长度是由地址总线的位数决定的,现在的计算机一般都是32位的地址总线,也就占4个字节。
HTTP协议是一种分布式的面向资源的网络应用层协议。
指一次和多次请求某一个资源应该具有同样的副作用。
幂等性是分布式系统设计中十分重要的概念。
为什么需要幂等性呢?我们先从一个例子说起,假设有一个从账户取钱的远程API(可以是HTTP的,也可以不是),我们暂时用类函数的方式记为:
bool withdraw(account_id, amount)
withdraw的语义是从account_id对应的账户中扣除amount数额的钱;如果扣除成功则返回true,账户余额减少amount;如果扣除失败则返回false,账户余额不变。值得注意的是:和本地环境相比,我们不能轻易假设分布式环境的可靠性。一种典型的情况是withdraw请求已经被服务器端正确处理,但服务器端的返回结果由于网络等原因被掉丢了,导致客户端无法得知处理结果。如果是在网页上,一些不恰当的设计可能会使用户认为上一次操作失败了,然后刷新页面,这就导致了withdraw被调用两次,账户也被多扣了一次钱。如图1所示:
这个问题的解决方案一是采用分布式事务,通过引入支持分布式事务的中间件来保证withdraw功能的事务性。分布式事务的优点是对于调用者很简单,复杂性都交给了中间件来管理。缺点则是一方面架构太重量级,容易被绑在特定的中间件上,不利于异构系统的集成;另一方面分布式事务虽然能保证事务的ACID性质,而但却无法提供性能和可用性的保证。
另一种更轻量级的解决方案是幂等设计。我们可以通过一些技巧把withdraw变成幂等的,比如:
int create_ticket()
bool idempotent_withdraw(ticket_id, account_id, amount)
create_ticket的语义是获取一个服务器端生成的唯一的处理号ticket_id,它将用于标识后续的操作。idempotent_withdraw和withdraw的区别在于关联了一个ticket_id,一个ticket_id表示的操作至多只会被处理一次,每次调用都将返回第一次调用时的处理结果。这样,idempotent_withdraw就符合幂等性了,客户端就可以放心地多次调用。
基于幂等性的解决方案中一个完整的取钱流程被分解成了两个步骤:1.调用create_ticket()获取ticket_id;2.调用idempotent_withdraw(ticket_id, account_id, amount)。虽然create_ticket不是幂等的,但在这种设计下,它对系统状态的影响可以忽略,加上idempotent_withdraw是幂等的,所以任何一步由于网络等原因失败或超时,客户端都可以重试,直到获得结果。如图2所示:
和分布式事务相比,幂等设计的优势在于它的轻量级,容易适应异构环境,以及性能和可用性方面。
HTTP协议本身是一种面向资源的应用层协议,但对HTTP协议的使用实际上存在着两种不同的方式:一种是RESTful的,它把HTTP当成应用层协议,比较忠实地遵守了HTTP协议的各种规定;另一种是SOA的,它并没有完全把HTTP当成应用层协议,而是把HTTP协议作为了传输层协议,然后在HTTP之上建立了自己的应用层协议。本文所讨论的HTTP幂等性主要针对RESTful风格的,不过正如上一节所看到的那样,幂等性并不属于特定的协议,它是分布式系统的一种特性;所以,不论是SOA还是RESTful的Web API设计都应该考虑幂等性。下面将介绍HTTP GET、DELETE、PUT、POST四种主要方法的语义和幂等性。
HTTP GET方法用于获取资源,不应有副作用,所以是幂等的。比如:
GET http://www.bank.com/account/123456
不会改变资源的状态,不论调用一次还是N次都没有副作用。请注意,这里强调的是一次和N次具有相同的副作用,而不是每次GET的结果相同。
GET http://www.news.com/latest-news
这个HTTP请求可能会每次得到不同的结果,但它本身并没有产生任何副作用,因而是满足幂等性的。
HTTP 的 DELETE方法用于删除资源,有副作用,但它应该满足幂等性。比如:
DELETE http://www.forum.com/article/4231
调用一次和N次对系统产生的副作用是相同的,即删掉id为4231的帖子;因此,调用者可以多次调用或刷新页面而不必担心引起错误。
比较容易混淆的是HTTP POST和PUT。POST和PUT的区别容易被简单地误认为“POST表示创建资源,PUT表示更新资源”;而实际上,二者均可用于创建资源,更为本质的差别是在幂等性方面。
POST所对应的URI并非创建的资源本身,而是资源的接收者。比如:
POST http://www.forum.com/articles
的语义是在
http://www.forum.com/articles
下创建一篇帖子,HTTP响应中应包含帖子的创建状态以及帖子的URI。两次相同的POST请求会在服务器端创建两份资源,它们具有不同的URI;所以,POST方法不具备幂等性。而PUT所对应的URI是要创建或更新的资源本身。比如:
PUT http://www.forum/articles/4231
的语义是创建或更新ID为4231的帖子。对同一URI进行多次PUT的副作用和一次PUT是相同的;因此,PUT方法具有幂等性。
在介绍了几种操作的语义和幂等性之后,我们来看看如何通过Web API的形式实现前面所提到的取款功能。很简单,用POST /tickets来实现create_ticket;用
PUT /accounts/account_id/ticket_id&amount=xxx
来实现idempotent_withdraw。值得注意的是严格来讲amount参数不应该作为URI的一部分,真正的URI应该是/accounts/account_id/ticket_id
,而amount应该放在请求的body中。这种模式可以应用于很多场合,比如:论坛网站中防止意外的重复发帖。
如果要追根溯源,幂等性是数学中的一个概念,表达的是N次变换与1次变换的结果相同。
体系结构的对齐和不对齐,是在时间和空间上的一个权衡。对齐节省了时间,但是浪费了空间。
对于char型数据,其自身对齐值为1,对于short型为2,对于int,float类型,其自身对齐值为4,对于double型,其自身对齐值为8,单位字节。
结构(struct)(或联合(union))的数据成员,第一个数据成员放在offset为0的地方,以后每个数据成员存储的起始位置要从该成员大小或者成员的子成员大小(只要该成员有子成员,比如说是数组,结构体等)的整数倍开始(比如int在32位机为4字节,则要从4的整数倍地址开始存储。
结构体作为成员:如果一个结构里有某些结构体成员,则结构体成员要从其内部最大元素大小的整数倍地址开始存储。(struct a里存有struct b,b里有char,int ,double等元素,那b应该从8的整数倍开始存储)。
结构体的总大小,也就是sizeof的结果,必须是其内部最大成员的整数倍。不足的要补齐。
使用预处理:
#pragma pack (n)
指定对齐值n。
typedef struct bb {
int id; //[0]..[3]
double weight; //[8]......[15] 原则1
float height; //[16]..[19],总长要为8的整数倍,补齐[20]...[23] 原则3
}BB;
typedef struct aa {
char name[2]; //[0],[1]
int id; //[4]..[7] 原则1
double score; //[8]......[15]
short grade; //[16],[17]
BB b; //[24]......[47] 原则2
}AA;
int main() {
AA a;
cout<<sizeof(a)<<" "<<sizeof(BB)<<endl;
return 0;
}
结果:
48 24
在代码前加一句
#pragma pack(1)
上面的代码输出为
32 16
因为:bb是4+8+4=16,aa是2+4+8+2+16=32;
这不是理想中的没有内存对齐的世界吗。没错,#pragma pack(1)
,告诉编译器,所有的对齐都按照1的整数倍对齐,换句话说就是没有对齐规则。
所以,#pragram pack(n)
的含义是:设置内存以n的倍数进行字节对齐。
不是所有的硬件平台都能访问任意地址上的任意数据,某些硬件平台只能在某些地址处取某些特定类型的数据,否则抛出硬件异常。
经过内存对齐之后,CPU的内存访问速度大大提升。
内存中存放的数据在我们普通程序员心中的印象,是由一个个字节有规律的组成的:
而实际上,CPU是这样看待的:
cpu把内存当成是一块一块的,块的大小可以是2,4,8,16 个字节,因此CPU在读取内存的时候是一块一块进行读取的,块的大小称为(memory granularity)内存读取粒度。
假设CPU要读取一个4字节大小的数据到寄存器中(假设内存读取粒度是4),分两种情况讨论:
1.数据从0字节开始
2.数据从1字节开始
解析:当数据从0字节开始的时候,直接将0-3四个字节完全读取到寄存器,结算完成了。
当数据从1字节开始的时候,问题很复杂,首先先将前4个字节读到寄存器,并再次读取4-7字节的数据进寄存器,接着把0、5、6、7字节的数据剔除,最后合并1,2,3,4字节的数据进寄存器,对一个内存未对齐的寄存器进行了这么多额外操作,大大降低了CPU的性能。
但是这还属于乐观情况,上文提到内存对齐的作用之一是平台的移植原因,因为只有部分CPU肯干,其他部分CPU遇到未对齐边界就直接罢工了。
Q1:构造函数能否重载,析构函数能否重载,为什么?
A1:构造函数可以,析构函数不可以。
Q2:析构函数为什么一般情况下要声明为虚函数?
A2:虚函数是实现多态的基础,当我们通过基类的指针去析构子类对象时候,如果不定义成虚函数,那只调用基类的析构函数,子类的析构函数将不会被调用。如果定义为虚函数,则子类父类的析构函数都会被调用。
Q3:什么情况下必须定义拷贝构造函数?
A3:当类的对象用于函数值传递时(值参数,返回类对象),拷贝构造函数会被调用。如果对象复制并非简单的值拷贝,那就必须定义拷贝构造函数。例如大的堆栈数据拷贝。如果定义了拷贝构造函数,那也必须重载赋值操作符。
几个例子:
typedef struct list_node {
int ivar;
char cvar;
double dvar;
struct list_node *next;
} list_node;
就用这个类型来定义一个变量:list_node ln; 假设现在求 ln.dvar 的地址与 ln 的地址之间相差多少个字节,用这个表达式:**(char )&ln.dvar - (char )&ln 即可求出,这个式子的原理是,用 & 符取到 ln.dvar 和 ln 的地址,强制将其转换为 char * 型并将二者相减,之所以要转换为 char * 型,是因为 char 型变量占一个字节,根据指针减法的特性,我们要求二者之间的字节数,当然要以单个的字节为单位。
因为成员的地址我们是已经知道了,再结合上面求偏移量的公式,我们只需要再知道这个成员在结构体中的偏移量,那么,就可以求出结构体的地址。
好了,求偏移量的基本原理就是这个,那么问题来了,假如你在调用函数的时候,只通过参数传入了某个结构体成员,函数中并没有该结构体对象本身,这样子上述方法就不管用了,那该怎么办?我们从另一个角度想问题,如果说 B - A 的差就是偏移量,那如果令 A 等于 0,那么 B 它本身的值是不是就是偏移量了?基于这个思路,我们来看看伟大的 Linux 内核是怎样实现求偏移量的操作的:
/*
* 选自 linux-2.6.7 内核源码
* filename: linux-2.6.7/include/linux/stddef.h
*/
#define offsetof(TYPE, MEMBER) ((size_t) &((TYPE *)0)->MEMBER)
linux 中是定义了一个宏 offsetof,这个宏接受两个参数,一个 TYPE 代表结构体的类型,另一个 MEMBER 代表结构体中的成员,我们看看后面的宏替换部分发生了什么,先看 ((TYPE *)0) 这个部分,它把 0 这个数字强制转换成 TYPE * 型的指针类型,这样 ((TYPE *)0) 这个整体就相当于一个指针指向了 0 这个地址,不管 0 这个地址是否合法,是否真的有这么一个结构体对象,它都会把以 0 地址为首的一片连续内存当成一个结构体对象操作,那么再看 ((TYPE *)0)->MEMBER 这个部分,((TYPE *)0) 这个指针要取结构体对象中的 MEMBER 成员,因为这只是读内存的操作,并没有写入数据,所以虽说地址不合法,但并不会发生段错误,这样取到 MEMBER 成员后,前面的 & 符就可以对 MEMBER 成员取地址了,刚才我也说了,B - A 的差是偏移量的话,如果 A 等于 0,那么 B 本身就是偏移量(技巧的关键所在),那正好对应现在的情况,((TYPE *)0) 本身就是以 0 地址为首进行操作,那么它取到的 MEMBER 成员所在的地址就是相对于结构体首地址的偏移量,然后再把这个地址强制转换成 size_t 类型,于是该成员的偏移量就得到了。有没有感觉很精妙。
知道成员偏移量了,那么求结构体对象本身的地址不就易如反掌了吗,成员的地址减去偏移量不就是结构体对象的首地址了吗,基于这个思路,我们再看看伟大的 Linux 是怎样实现的:
/*
* 选自 linux-2.6.7 内核源码
* filename: linux-2.6.7/include/linux/stddef.h
*/
#define container_of(ptr, type, member) ({ \
const typeof( ((type *)0)->member ) *__mptr = (ptr); \
(type *)( (char *)__mptr - offsetof(type,member) );})
优先级的确定需要考虑如下情况:
1、对I/O型进程,让其进入最高优先级队列,以及时响应需要I/O 交互的进程。通常执行一个小的时间片,在该时间片内要求可处理完一次I/O请求的数据,然后转入到阻塞队列。
2、对计算型进程,每次执行完时间片后进入更低级队列。最终采用最大时间片来执行。
3、对I/O次数不多,而主要是CPU处理的进程。在I/O完成后,放回优先I/O请求时离开队列,以避免每次到最高优先级队列后再逐次下降。
4、为适应一个进程在不同时间段的运行特点,I/O完成时,提高优先级;时间片用完时,降低优先级。
Linux使用cfs调度算法。通过处理器时间占比决定一个进程总共(注意不是一次,因为cfs算法没有时间片的概念)执行多久,通过vruntime来决定调度哪个进程。
进程cpu资源分配就是指进程的优先权(priority)。优先权高的进程有优先执行权利。
ps -l
其中:
PRI :代表这个进程可被执行的优先级,其值越小越早被执行
NI :代表这个进程的nice值
PRI也还是比较好理解的,即进程的优先级,或者通俗点说就是程序被CPU执行的先后顺序,此值越小进程的优先级别越高。那NI呢?就是我们所要说的nice值了,其表示进程可被执行的优先级的修正数值。如前面所说,PRI值越小越快被执行,那么加入nice值后,将会使得PRI变为:PRI(new)=PRI(old)+nice。这样,当nice值为负值的时候,那么该程序将会优先级值将变小,即其优先级会变高,则其越快被执行。
到目前为止,更需要强调一点的是,进程的nice值不是进程的优先级,他们不是一个概念,但是进程nice值会影响到进程的优先级变化。
1、互斥条件:一个资源每次只能被一个进程使用
2、请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放
3、不剥夺条件:进程已获得的资源,在未使用完之前,不能强行剥夺
4、循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系。
这两个命令都是用来查看使用空间的。
disk usage,是通过搜索文件来计算每个文件的大小然后累加,du能看到的文件只是一些当前存在的,没有被删除的。他计算的大小就是当前他认为存在的所有文件大小的累加和。
其中的4
指的是4KB。
其中的351
指的是351B
disk free,通过文件系统来快速获取空间大小的信息,当我们删除一个文件的时候,这个文件不是马上就在文件系统当中消失了,而是暂时消失了,当所有程序都不用时,才会根据OS的规则释放掉已经删除的文件, df记录的是通过文件系统获取到的文件的大小,df比du强的地方就是能够看到已经删除的文件,而且计算大小的时候,把这一部分的空间也加上了,更精确了。
当文件系统也确定删除了该文件后,这时候du与df就一致了。
进程与线程的选择取决以下几点:1、需要频繁创建销毁的优先使用线程;因为对进程来说创建和销毁一个进程代价是很大的。2、线程的切换速度快,所以在需要大量计算,切换频繁时用线程,还有耗时的操作使用线程可提高应用程序的响应3、因为对CPU系统的效率使用上线程更占优,所以可能要发展到多机分布的用进程,多核分布用线程;4、并行操作时使用线程,如C/S架构的服务器端并发线程响应用户的请求;5、需要更稳定安全时,适合选择进程;需要速度时,选择线程更好。
因为我的项目中需要对数据段的数据共享,可以被多个程序所修改,所以使用线程来完成此操作,无需加入复杂的通信机制,使用进程需要添加复杂的通信机制实现数据段的共享,增加了我的代码的繁琐,而且使用线程开销小,项目运行的速度快,效率高。
如果只用进程的话,虽然安全性高,但是对代码的简洁性不好,程序结构繁琐,开销比较大,还需要加入复杂的通信机制,会使得我的项目代码量大大增加,切换速度会变的很慢,执行效率降低不少。
fork()系统调用通过复制一个现有进程来创建一个全新的进程(进程的另外一个名字叫做任务,例如,Windows中的任务管理器)。
进程被存放在一个叫做任务队列的双向循环链表中。 链表中的每一项都是类型为task_struct称为进程描述符的结构。(它包含一个具体进程的所有信息)
内核通过一个唯一的PID来标识每个进程。
linux的fork()使用写时拷贝页实现。 写时拷贝:一种可以推迟甚至免除拷贝数据的技术 。 使用写时拷贝,内核并不复制整个进程地址空间,而是让父子进程共享同一个拷贝。只有需要写入的时候,数据才会被复制,从而各个进程拥有各自的拷贝。 资源的复制只有在需要写入的时候才进行,在这之前只是以只读方式共享,本质是将地址空间上的页的拷贝推迟到实际发生写入的时候
linux通过clone()系统调用实现fork()。
fork(),vfork(),和_clone()库函数都根据各自需要的参数标志去调用clone(),然后由clone()去调用do_fork()。 do_fork()完成了创建中的大部分工作,它定义在kernel/fork.c中。该函数调用copy_process(),接下来copy_process()实现的工作如下 :
1、调用dup_task_struct()为新进程创建一个内核栈,threaad_info结构和task_struct,这些值与当前进程的值相同。此时,子进程和父进程的描述符是完全相同的。
2、检查新创建的这个子进程后,当前用户所拥有的进程数目没有超出给他分配的资源的限制。
3、现在,子进程着手使自己与父进程区别拷来。进程描述符内的许多成员都要被清0或设为初始值。进程描述符的成员值并不是继承而来的,而主要是统计信息,进程描述符中大多数的数据都是共享的。
4、接下来,子进程的状态被设置为TASK_UNINTERRUPTIBLE(不可中断)以保证它不会投入运行。
5、copy_process()调用copy_flags()以更新task_struct的flags成员。表明进程是否拥有超级用户权限的PF_SUPERPRIV标志被清0.表名进程还没有调用exec()函数的PF_FORKNOEXEC标志被设置。
6、调用get_pid()为新进程获取一个有效的PID
7、根据传递给clone()的参数标志,copy_process()拷贝或共享打开的文件,文件系统信息,信号处理函数,进程地址空间和命名空间等。在一般情况下,这些资源会被给定进程的所有线程共享;否则,这些资源对每个进程是不同的,因此被拷贝到这里。
8、让父进程和子进程平分剩余的时间片
9、最后copy_process()做扫尾工作并返回一个指向子进程的指针。
再回到do_fork()函数,如果copy_process函数成功返回,新创建的子进程被唤醒并让其投入运行。内核有意选择子进程首先执行。因为一般子进程都会马上调用exec()函数,这样可以避免写时拷贝的额外开销,如果父进程首先执行的话,有可能会开始向地址空间写入。
一般我们是通过动态创建子进程(或者子线程)来实现并发服务器的,这样的缺点:
1、动态创建进程(或线程)比较耗费时间,这将导致较慢的客户响应。
2、动态创建的子进程通常只用来为一个客户服务,这样导致了系统上产生大量的细微进程(或线程)。进程和线程间的切换将消耗大量CPU时间。
3、由于系统的资源有限,能够创建的子进程(或线程)的数量有限,所以响应客户端请求的数量有上限。
4、动态创建的子进程是当前进程的完整映像,当前进程必须谨慎的管理其分配的文件描述符和堆内存等系统资源,否则子进程可能复制这些资源,从而使系统的可用资源急剧下降,进而影响服务器的性能。
由服务器预先创建的一组子进程,这些子进程的数目在3~10个之间。httpd守护进程就是使用了包含7个子进程的进程池来实现并发的。线程池中的线程数量应该和CPU数量差不多。
1、他们都运行着相同的代码,具有相同的属性,比如优先级,PGID(组识别码)等。
2、进程池在服务器启动之初就创建好了,所以每个子进程都相对"干净",即它们没有打开不必要的文件描述符(从父进程继承而来)。
3、也不会错误地使用大块的堆内存(从父进程复制得到)。
1、主进程使用某种算法来主动选择子进程。(最简单、最常用的算法是随机算法和Round-Robin(轮流选取)算法)
2、主进程和所有子进程通过一个共享的工作队列来实现同步:
子进程都睡眠在该工作队列上,当有新的任务到来时,主进程将任务添加到工作队列中。
这将唤醒正在等待任务的子进程,不过只有一个子进程将获得新任务的“接管权”,它可以从工作队列中取出任务并执行之,而其他子进程将继续睡眠在工作队列上。
3、当选择好子进程后,主进程还需要使用某种通知机制来告诉目标子进程有新进程需要处理,并传递必要的数据。最简单的方法是,在父进程和子进程之间预先建立好一条管道,然后通过该管道来实现所有进程间通信(当然,要预先定义好一套协议来规范管道的使用)。在父线程和子线程之间传递数据就要简单的多,因为我们可以把这些数据定义全局的,那么他们本身就是被所有线程共享的。
(这实际上是一个禁用的问题,关键是如何做好禁用会显得更加自然)
一般情况下,编写一个类,是可以在栈或者堆分配空间。但有些时候,你想编写一个只能在栈或者只能在堆上面分配空间的类。这能不能实现呢?仔细想想,其实也是可以滴。
在C++中,类的对象建立分为两种,一种是静态建立,如:
A a;
另一种是动态建立,如:
A* ptr=new A;
这两种方式是有区别的。
是由编译器为对象在栈空间中分配内存,是通过直接移动栈顶指针,挪出适当的空间,然后在这片内存空间上调用构造函数形成一个栈对象。使用这种方法,直接调用类的构造函数。(我为什么没有分配内存的那一步呢?因为实在栈上面分配内存,所以只需要移动栈顶指针即可,不需要额外的分配内存的操作,因为是由编译器分配的。)
是使用new运算符将对象建立在堆空间中。这个过程分为两步,第一步是执行operator new()函数,在堆空间中搜索合适的内存并进行分配;第二步是调用构造函数构造对象,初始化这片内存空间。这种方法,间接调用类的构造函数。
就是不能静态建立类对象,即不能直接调用类的构造函数。
容易想到将构造函数设为私有。在构造函数私有之后,无法在类外部调用构造函数来构造类对象,只能使用new运算符
来建立对象。然而,前面已经说过,new运算符
的执行过程分为两步,C++
提供new运算符
的重载,其实是只允许重载operator new()
函数,而operator new()
函数只用于分配内存,无法提供构造功能。所以设置构造函数为私有的,也会使得类对象不能在堆上面初始化数据。因此,这种方法不可以。
当对象建立在栈上面时,是由编译器分配内存空间的,调用构造函数来构造栈对象。当对象使用完后,编译器会调用析构函数来释放栈对象所占的空间。编译器管理了对象的整个生命周期。如果编译器无法调用类的析构函数,情况会是怎样的呢?比如,类的析构函数是私有的,编译器无法调用析构函数来释放内存。所以,编译器在为类对象分配栈空间时,会先检查类的析构函数的访问性,其实不光是析构函数,只要是非静态的函数,编译器都会进行检查。如果类的析构函数是私有的,则编译器不会在栈空间上为类对象分配内存。
因此,将析构函数设为私有,类对象就无法建立在栈上了。
class A {
public:
A() {}
void destory(){delete this;}
private:
~A() {}
};
试着使用A a;
来建立对象,编译报错,提示析构函数无法访问。这样就只能使用new操作符
来建立对象,构造函数是公有的,可以直接调用。类中必须提供一个destory
函数,来进行内存空间的释放。类对象使用完成后,必须调用destory
函数。
如果A作为其它类的基类,则析构函数通常要设为virtual
,然后在子类重写,以实现多态。
因此析构函数不能设为private
。
还好C++
提供了第三种访问控制,protected
。
将析构函数设为protected
可以有效解决这个问题,类外无法访问protected
成员,子类则可以访问。
使用new
建立对象,却使用destory
函数释放对象,而不是使用delete
。
(使用delete
会报错,因为delete
一个对象的指针,会调用对象的析构函数,而析构函数类外不可访问。这种使用方式比较怪异。)
为了统一,可以将构造函数设为protected
,然后提供一个public
的static
函数来完成构造,这样不使用new
,而是使用一个函数来构造,使用一个函数来析构。
代码如下,类似于单例模式:
class A
{
protected:
A(){}
~A(){}
public:
static A* create()
{
return new A();
}
void destory()
{
delete this;
}
};
这样,调用create()函数在堆上创建类A对象,调用destory()函数释放内存。
只有使用new运算符
,对象才会建立在堆上,因此,只要禁用new运算符就可以实现类对象只能建立在栈上。
虽然你不能影响new operator
的能力(因为那是C++语言内建的),但是你可以利用一个事实:new operator
总是先调用 operator new
,而后者我们是可以自行声明重写的。
因此,将operator new()
设为私有即可禁止对象被new
在堆上。
class A
{
private:
void* operator new(size_t t){} // 注意函数的第一个参数和返回值都是固定的
void operator delete(void* ptr){} // 重载了new就需要重载delete
public:
A(){}
~A(){}
};
切换由内核控制,当线程进行切换的时候,由用户态转化为内核态。切换完毕要从内核态返回用户态;可以很好的利用smp(对称多处理器),即利用多核cpu。windows线程就是这样的。
由操作系统内核创建和撤销。内核维护进程及线程的上下文信息以及线程切换。一个内核线程由于I/O操作而阻塞,不会影响其它线程的运行。Windows NT和2000/XP支持内核线程。
内核级线程驻留在内核空间,它们是内核对象。有了内核线程,每个用户线程被映射或绑定到一个内核线程。用户线程在其生命期内都会绑定到该内核线程。一旦用户线程终止,两个线程都将离开系统。这被称作"一对一"线程映射(一对一指的是,进程中的每一个用户线程都对应一个内核线程)。操作系统调度器管理、调度并分派这些线程。运行时库为每个用户级线程请求一个内核级线程。操作系统的内存管理和调度子系统必须要考虑到数量巨大的用户级线程。您必须了解每个进程允许的线程的最大数目是多少。操作系统为每个线程创建上下文。进程的每个线程在资源可用时都可以被指派到处理器内核。
指不需要内核支持而在用户程序中实现的线程,其不依赖于操作系统核心,应用进程利用线程库提供创建、同步、调度和管理线程的函数来控制用户线程。不需要用户态/核心态切换,速度快,操作系统内核不知道多线程的存在,因此一个线程阻塞将使得整个进程(包括它的所有线程)阻塞。由于这里的处理器时间片分配是以进程为基本单位,所以每个线程执行的时间相对减少。
用户级线程驻留在用户空间,运行时库管理这些线程,它也位于用户空间。它们对于操作系统是不可见的,因此无法被调度到处理器内核。每个线程并不具有自身的线程上下文。因此,就线程的同时执行而言,任意给定时刻每个进程只能够有一个线程在运行,而且只有一个处理器内核会被分配给该进程。对于一个进程,可能有成千上万个用户级线程,但是它们对系统资源没有影响。运行时库调度并分派这些线程。如同在图6-1(a)中看到的那样,库调度器从进程的多个线程中选择一个线程,然后该线程和该进程允许的一个内核线程关联起来。内核线程将被操作系统调度器指派到处理器内核。用户级线程是一种"多对一"的线程映射(多对一指的是一个进程中的多个用户线程共用一个内核线程)。
1、内核支持线程是OS内核可感知的,而用户级线程是OS内核不可感知的。
2、用户级线程的创建、撤消和调度不需要OS内核的支持,是在语言(如Java)这一级处理的;而内核支持线程的创建、撤消和调度都需OS内核提供支持,而且与进程的创建、撤消和调度大体是相同的。
3、用户级线程执行系统调用指令时将导致其所属进程被中断(异常,即软中断),而内核级线程执行系统调用指令时,只导致该线程被中断。
4、在只有用户级线程的系统内,CPU调度还是以进程为单位,处于运行状态的进程中的多个线程,由用户程序控制线程的轮换运行;在有内核支持线程的系统内,CPU调度则以线程为单位,由OS的线程调度程序负责线程的调度。
5、用户级线程的程序实体是运行在用户态下的程序,而内核级线程的程序实体则是可以运行在任何状态下的程序。
当有多个处理机时,一个进程的多个线程可以同时执行。
由内核进行调度。
1、 线程的调度不需要内核直接参与,控制简单。
2、可以在不支持线程的操作系统中实现。
3、创建和销毁线程、线程切换代价、线程管理的代价比内核线程少得多。
4、允许每个进程定制自己的调度算法,线程管理比较灵活。这就是必须自己写管理程序,与内核线程的区别。
5、线程能够利用的表空间和堆栈空间比内核级线程多。
6、同一进程中只能同时有一个线程在运行,如果有一个线程使用了系统调用而阻塞,那么整个进程都会被挂起。
资源调度按照进程进行,多个处理机下,同一个进程中的线程只能在同一个处理机下分时复用。
由于用户级线程没有时间片概念,所以每个线程必须运行一段时间后将CPU让给其他的线程使用,否则,该线程将独占CPU。
较差并发能力。
混合线程实现是用户线程和内核线程的交叉,使得库和操作系统都可以管理线程。用户线程由运行时库调度器管理,内核线程由操作系统调度器管理。在这种实现中,进程有着自己的内核线程池。可运行的用户线程由运行时库分派并标记为准备好执行的可用线程。操作系统选择用户线程并将它映射到线程池中的可用内核线程。多个用户线程可以分配给相同的内核线程。进程A在它的线程池中有两个内核线程,而进程B有3个内核线程。进程A的用户线程2和3被映射到内核线程(2)。进程B有5个线程,用户线程1和2映射到同一个内核线程(3),用户线程4和5映射到内核同一个内核线程(5)。当创建新的用户线程时,只需要简单地将它映射到线程池中现有的一个内核线程即可。这种实现使用了"多对多"线程映射。该方法中尽量使用多对一映射。很多用户线程将会映射到一个内核线程。因此,对内核线程的请求将会少于用户线程的数目。
内核线程池不会被销毁和重建,这些线程总是位于系统中。它们会在必要时分配给不同的用户级线程,而不是当创建新的用户级线程时就创建一个新的内核线程。
- 新增元素:Vector通过一个连续的数组存放元素,如果集合已满,在新增数据的时候,就要分配一块更大的内存,将原来的数据复制过来,释放之前的内存,在插入新增的元素;
- 对vector的任何操作,一旦引起空间重新配置,指向原vector的所有迭代器就都失效了 ;
- 初始时刻vector的capacity为0,塞入第一个元素后capacity增加为1;
- 不同的编译器实现的扩容方式不一样,VS2015中以1.5倍扩容,GCC以2倍扩容。
#include<iostream>
#include<vector>
using namespace std;
int main()
{
vector<int> vec;
cout << vec.capacity() << endl;
for (int i = 0; i<10; ++i) {
vec.push_back(i);
cout << "size: " << vec.size() << endl;
cout << "capacity: " << vec.capacity() << endl;
}
return 0;
}
注意:capacity的大小一定不小于size的大小。
可以根据输出看到,GCC中的vector是以2倍的方式扩容(capacity)的。
为什么要成倍的扩容而不是一次增加一个固定大小的容量呢?
采用成倍方式扩容,可以保证常数的时间复杂度(达到O(1)的时间复杂度),而增加指定大小的容量只能达到O(n)的时间复杂度,因此,使用成倍的方式扩容。
对于vector而言,添加和删除操作可能使容器的部分或者全部迭代器失效。
如果当前容器中已经存在了16个元素,现在又要添加一个元素到容器中,但是内存中紧跟在这16个元素后面没有一个空闲空间。于是vector必须重新分配存储空间,用来存放原来的元素以及新添加的元素:存放在旧存储空间的元素被复制到新的存储空间里,接着插入新的元素,最后撤销旧的存储空间。这种情况发生,一定会导致vector容器的所有迭代器都失效。
1、当插入(push_back)一个元素后,end操作返回的迭代器肯定失效。 2、当插入(push_back)一个元素后,capacity返回值与没有插入元素之前相比有改变,则需要重新加载整个容器,此时first和end操作返回的迭代器都会失效。 3、当进行删除操作(erase,pop_back)后,指向删除点的迭代器全部失效;指向删除点后面的元素的迭代器也将全部失效。
具有多态性质的基类的析构函数应该为virtual,或者说任何带有virtual函数的类都应该有virtual析构函数。
例如:
#include <iostream>
using namespace std;
class A {
public:
A() { cout<<"A::A()"<<endl; }
~A() { cout<<"A::~A()"<<endl; }
/* some code */
};
class B: public A {
public:
B() { cout<<"B::B()"<<endl; }
~B() { cout<<"B::~B()"<<endl; }
/* some code */
};
主函数1:
int main(int argc, char const *argv[]) {
B b;
/* some code */
return 0;
}
结果:
主函数2:
int main(int argc, char const *argv[]) {
B b;
A* p = &b;
/* some code */
return 0;
}
结果:
主函数3:
int main(int argc, char const *argv[]) {
B b;
A* p = &b;
/* some code */
delete p;
/* some code */
return 0;
}
结果:
如果以上代码(主函数3)出现在你的项目中,那么就会造成灾难性的后果。在上述代码中,B类的对象经由一个基类(A类)指针被删除,但是基类(A类)有个non-virtual析构函数,这就导致其内的B类成分(声明于B类的成员变量)没有被销毁(因为B类的析构函数没有被调用),然而其基类成分(也就是A类这一部分)通常会被销毁,于是导致一个诡异的“局部销毁”对象。资源泄露、败坏数据结构等问题都会因此而产生。
消除这个问题很简单,只要给基类加上一个virtual析构函数即可:
class A {
public:
A() { cout<<"A::A()"<<endl; }
virtual ~A() { cout<<"A::~A()"<<endl; }
/* some code */
};
结果:
也就是说,现在删除派生类对象就会如我们想象的那样销毁整个对象,包括所有的派生类部分。但是如果类中没有virtual函数,不打算被当做基类,那么将析构函数声明为virtual并不是一件好事。
对此我们只要记住:只有当类里面含有至少一个virtual函数,才为它声明virtual析构函数。
不要在构造函数和析构函数内调用virture函数,否则会给你带来意料不到的结果。
Empty() {
}
Empty(const Empty& copy) {
}
Empty& operator = (const Empty& copy) {
}
~Empty() {
}
这些函数只有在第一次使用它们的时候才会生成,他们都是inline并且public的。如果想禁止生成这些函数,可以将它们定义成private函数。
文件描述符(file descriptor)是内核为了高效管理已被打开的文件所创建的索引,其是一个非负整数(通常是小整数),用于指代被打开的文件,所有执行I/O操作的系统调用都通过文件描述符。程序刚刚启动的时候,0是标准输入,1是标准输出,2是标准错误。如果此时去打开一个新的文件,它的文件描述符会是3。POSIX标准要求每次打开文件时(含socket)必须使用当前进程中最小可用的文件描述符号码,因此,在网络通信过程中稍不注意就有可能造成串话。
标准文件描述符图如下:
文件描述与打开的文件对应模型如下图:
每一个文件描述符会与一个打开文件相对应,同时,不同的文件描述符也会指向同一个文件。相同的文件可以被不同的进程打开也可以在同一个进程中被多次打开。系统为每一个进程维护了一个文件描述符表,该表的值都是从0开始的,所以在不同的进程中你会看到相同的文件描述符,这种情况下相同文件描述符有可能指向同一个文件,也有可能指向不同的文件。具体情况要具体分析,要理解具体其概况如何,需要查看由内核维护的3个数据结构。
1、进程级的文件描述符表
2、系统级的打开文件描述符表
3、文件系统的i-node表
下图展示了文件描述符、打开的文件句柄以及i-node之间的关系,图中,两个进程拥有诸多打开的文件描述符。
在进程A中,文件描述符1和30都指向了同一个打开的文件句柄(标号23)。这可能是通过调用dup()、dup2()、fcntl()或者对同一个文件多次调用了open()函数而形成的。
进程A的文件描述符2和进程B的文件描述符2都指向了同一个打开的文件句柄(标号73)。这种情形可能是在调用fork()后出现的(即,进程A、B是父子进程关系),或者当某进程通过UNIX域套接字将一个打开的文件描述符传递给另一个进程时,也会发生。再者是不同的进程独自去调用open函数打开了同一个文件,此时进程内部的描述符正好分配到与其他进程打开该文件的描述符一样。
1、由于进程级文件描述符表的存在,不同的进程中会出现相同的文件描述符,它们可能指向同一个文件,也可能指向不同的文件
2、两个不同的文件描述符,若指向同一个打开文件句柄,将共享同一文件偏移量。因此,如果通过其中一个文件描述符来修改文件偏移量(由调用read()、write()或lseek()所致),那么从另一个描述符中也会观察到变化,无论这两个文件描述符是否属于同一进程的,还是属于不同进程的,情况都是如此。
3、文件描述符标志(即,close-on-exec)为进程和文件描述符所私有。对这一标志的修改将不会影响同一进程或不同进程中的其他文件描述符。
char* strcpy(char* strDest,const char* strSrc) {
if ((NULL == strDest) || (NULL == strSrc)) // [1]
throw "Invalid argument(s)"; // [2]
char * strDestCopy = strDest; // [3]
while ((*strDest++ = *strSrc++) != '\0'); // [4]
return strDestCopy;
}
[1]
(A)不检查指针的有效性,说明答题者不注重代码的健壮性。
(B)检查指针的有效性时使用
((!strDest) || (!strSrc)) 或者(!(strDest&&strSrc))
说明答题者对C语言中类型的隐式转换没有深刻认识。在本例中char *转换为bool即是类型隐式转换,这种功能虽然灵活,但更多的是导致出错概率增大和维护成本升高。所以C++专门增加了bool、true、false三个关键字以提供更安全的条件表达式。
(C)检查指针的有效性时使用
((strDest == 0) || (strSrc == 0))
说明答题者不知道使用常量的好处。直接使用字面常量(如本例中的0)会降低程序的可维护性。0虽然简单,但程序中可能出现很多处对指针的检查,万一出现笔误,编译器不能发现,生成的程序内含逻辑错误,很难排除。而使用NULL代替0,如果出现拼写错误,编译器就会检查出来。
[2]
(A)如果使用:
return new string("Invalid argument(s)");
说明答题者根本不知道返回值的用途(这个函数返回值的作用是找到字符串的首地址),并且他对内存泄漏也没有警惕心。从函数中返回函数体内分配的内存是十分危险的做法,他把释放内存的义务抛给不知情的调用者,绝大多数情况下,调用者不会释放内存,这导致内存泄漏。
(B)如果使用:
return 0;
说明答题者没有掌握异常机制。调用者有可能忘记检查返回值,调用者还可能无法检查返回值(见后面的链式表达式)。妄想让返回值肩负返回正确值和异常值的双重功能,其结果往往是两种功能都失效。应该以来代替返回值,这样可以减轻调用者的负担、使错误不会被忽略、增强程序的可维护性。
[3]
(A)忘记保存原始的strDest值,说明答题者逻辑思维不严密。
[4]
(A)如果循环写成
while (*strDestCopy++=*strSrc++);
理由同[1](B)。
(B)如果循环写成
while (strSrc!='\0') *strDest++=*strSrc++;
说明答题者对边界条件的检查不力。循环体结束后,strDest字符串的末尾没有正确地加上'\0'。
const局部变量存储在栈中,代码块结束时释放。
const
用于类中的成员变量的时候,将类成员变量变为只读属性。所以不可以直接在类的构造函数中初始化const
的成员变量。
const成员变量只可以用成员初始化列表来初始化。(成员初始化列表是先于构造函数的函数体执行,且初始化的顺序是与类中声明变量的顺序一致,而不是和成员初始化列表的顺序一致)
与常量一样,引用的初始化也只能使用成员初始化列表来进行:
class student {
public :
student(int i,int j) : a(i), b(j) {}
private:
const int a;
int &b;
};
但是在大多数情况下,两者实际上没有区别。
由于类成员初始化总在构造函数执行之前(记住)
1)从必要性:
a、成员是类或结构,且构造函数带参数:成员初始化时无法调用缺省(无参)构造函数。
b、成员是常量或引用:成员无法赋值,只能被初始化。
2)从效率上:
a、如果在类构造函数里赋值:在成员变量初始化时会调用一次其默认的构造函数,在类构造函数里又会调用一次成员的构造函数再赋值(不理解)。
b、如果在类构造函数使用初始化列表:仅在初始化列表里调用一次成员的构造函数并赋值(不理解)。
让我们先看一下第一个原因——必要性。设想你有一个类成员,它本身是一个类或者结构,而且只有一个带一个参数的构造函数。
class CMember {
public:
CMember(int x) { ... }
};
因为CMember有一个显式声明的构造函数,编译器不产生一个缺省构造函数(不带参数,即无参构造函数),所以没有一个整数就无法创建CMember的一个实例。
CMember* pm = new CMember; // Error!!
CMember* pm = new CMember(2); // OK
如果Cmember是另一个类的成员,你怎样初始化它呢?你必须使用成员初始化列表。
class CMyClass {
private:
CMember m_member;
public:
CMyClass();
};
//必须使用成员初始化列表
CMyClass::CMyClass() : m_member(2) {
...
}
没有其它办法将参数传递给m_member,如果成员是一个常量对象或者引用也是一样。根据C++的规则,常量对象和引用不能被赋值,它们只能被初始化。
成员初始化列表是给数据成员分配内存空间时就进行初始化,就是说分配一个数据成员只要冒号后有此数据成员的赋值表达式(此表达式必须是括号赋值表达式),那么,在分配了内存空间后、在进入函数体之前,给数据成员赋值,就是说初始化这个数据成员的时候,函数体还未执行。
对于在构造函数函数体中初始化,是在所有的数据成员被分配内存空间后才进行的。
// CStack.h
extern "C" {
#include "Stack.h";
}
从硬件和软件(数据结构)两个方面答
线程间的通信目的主要是用于线程同步,所以线程没有像进程通信中的用于数据交换的通信机制。
例如互斥锁:
提供了以排他方式防止数据结构被并发修改的方法。
包括无名线程信号量和命名线程信号量。
类似进程间的信号处理。
该函数的功能为客户端主动连接服务器,建立连接是通过三次握手,而这个连接的过程是由内核完成,不是这个函数完成的,这个函数的作用仅仅是通知 Linux 内核,让 Linux 内核自动完成TCP 三次握手连接。
通常的情况,客户端的 connect() 函数默认会一直阻塞,直到三次握手成功或超时失败才返回。
对于服务器,它是被动连接的。举一个生活中的例子,通常的情况下,移动的客服(相当于服务器)是等待着客户(相当于客户端)电话的到来。而这个过程,需要调用listen()函数。
#include<sys/socket.h>
int listen(int sockfd, int backlog);
listen() 函数的主要作用就是将套接字( sockfd )变成被动的连接监听套接字(被动等待客户端的连接),至于参数 backlog 的作用是设置内核中连接队列的长度(这个长度有什么用,后面做详细的解释),TCP 三次握手也不是由这个函数完成,listen()的作用仅仅告诉内核一些信息。 这里需要注意的是,listen()函数不会阻塞,它主要做的事情为,将该套接字和套接字对应的连接队列长度告诉 Linux内核,然后,listen()函数就结束。 这样的话,当有一个客户端主动连接(connect()),Linux 内核就自动完成TCP 三次握手,将建立好的连接自动存储到队列中,如此重复。 所以,只要 TCP 服务器调用了 listen(),客户端就可以通过 connect() 和服务器建立连接,而这个连接的过程是由内核完成。
accept()函数功能是,从处于 established 状态的连接队列头部取出一个已经完成的连接,如果这个队列没有已经完成的连接,accept()函数就会阻塞,直到取出队列中已完成的用户连接为止。
如果,服务器不能及时调用 accept() 取走队列中已完成的连接,队列满掉后会怎样呢?
会延时连接,而且accept()未必能把已经建立好的连接全部取出来(如:当队列的长度指定为 0 ),写程序时服务器的 listen() 的第二个参数最好还是根据需要填写,写太大不好(具体可以看cat /proc/sys/net/core/somaxconn,默认最大值限制是 128),浪费资源,写太小也不好,延时建立连接。
class EX{
public:
void constFunction() const;
};
通过将类成员函数声明为const,以表示这个函数不可以修改类对象。任何不可以修改数据成员的函数都应该声明为const,如果在编写const成员函数时,不慎修改了数据成员, 或者调用了其他的非const函数,则此时编译器会指出错误。(所以,给函数加上const是为了提高程序的健壮性)
在相同的函数参数及相同的名字的情况下,const函数与非const函数可以构成重载函数,但是const成员函数不能改变任何的非静态变量。
-
const对象默认调用const成员函数,非const对象默认调用非const成员函数。
-
若非const对象想调用const成员函数,则需要显示的转化,例如
(const Student&)obj.getAge();
-
若const对象想调用非const成员函数,同理进行强制类型转换
const_cast < Student&>(constObj).getAge(); // 注意constObj一定要加括号
HTTP的长连接和短连接本质上是TCP长连接和短连接。
在HTTP/1.0中,默认使用的是短连接。也就是说,浏览器和服务器每进行一次HTTP操作,就建立一次连接,但任务结束就中断连接。如果客户端浏览器访问的某个HTML或其他类型的 Web页中包含有其他的Web资源,如JavaScript文件、图像文件、CSS文件等;当浏览器每遇到这样一个Web资源,就会建立一个HTTP会话。
但从 HTTP/1.1起,默认使用长连接,用以保持连接特性。使用长连接的HTTP协议,会在响应头有加入这行代码:
Connection:keep-alive
如果client使用http1.1协议,但又不希望使用长链接,则需要在header中指明connection的值为close;如果server方也不想支持长链接,则在response中也需要明确说明connection的值为close。
不论request还是response的header中包含了值为close的connection,都表明当前正在使用的tcp链接在请求处理完毕后会被断掉。以后client再进行新的请求时就必须创建新的tcp链接了。 HTTP Connection的 close设置允许客户端或服务器中任何一方关闭底层的连接双方都会要求在处理请求后关闭它们的TCP连接。
这是用户态进程主动要求切换到内核态的一种方式,用户态进程通过系统调用申请使用操作系统提供的服务程序完成工作,比如fork()实际上就是执行了一个创建新进程的系统调用。而系统调用的机制其核心还是使用了操作系统为用户特别开放的一个中断来实现,例如Linux的int 80h中断。
当CPU在执行运行在用户态下的程序时,发生了某些事先不可知的异常,这时会触发由当前运行进程切换到处理此异常的内核相关程序中,也就转到了内核态,比如缺页异常。
当外围设备完成用户请求的操作后,会向CPU发出相应的中断信号,这时CPU会暂停执行下一条即将要执行的指令转而去执行与中断信号对应的处理程序,如果先前执行的指令是用户态下的程序,那么这个转换的过程自然也就发生了由用户态到内核态的切换。比如硬盘读写操作完成,系统会切换到硬盘读写的中断处理程序中执行后续操作等。
这3种方式是系统在运行时由用户态转到内核态的最主要方式,其中系统调用可以认为是用户进程主动发起的,异常和外围设备中断则是被动的。
运行级别0:系统停机状态,系统默认运行级别不能设为0,否则不能正常启动 运行级别1:单用户工作状态,root权限,用于系统维护,禁止远程登陆 运行级别2:多用户状态(没有NFS) 运行级别3:完全的多用户状态(有NFS,即网络文件系统,它允许网络中的计算机之间通过TCP/IP网络共享资源),登陆后进入控制台命令行模式 运行级别4:系统未使用,保留 运行级别5:X11控制台,登陆后进入图形GUI模式 运行级别6:系统正常关闭并重启,默认运行级别不能设为6,否则不能正常启动
使用 & 在程序结尾来让程序自动运行。
可能我们的程序只是普通程序而已,一般这种程序即使使用 & 结尾,如果终端关闭,那么程序也会被关闭。为了能够后台运行,我们需要使用nohup这个命令,比如我们有个start.sh需要在后台运行,并且希望在后台能够一直运行,那么就使用nohup:
nohup /root/start.sh &
Linux 与其他类 UNIX 系统一样并不区分文件与目录:目录是记录了其他文件名的文件。使用命令 mkdir 创建目录时,若期望创建的目录的名称与现有的文件名(或目录名)重复,则会创建失败。
我们知道文件都有文件名与数据,这在 Linux 上被分成两个部分:用户数据 (user data) 与元数据 (metadata)。用户数据,即文件数据块 (data block),数据块是记录文件真实内容的地方;而元数据则是文件的附加属性,如文件大小、创建时间、所有者等信息。在 Linux 中,元数据中的 inode 号(inode 是文件元数据的一部分但其并不包含文件名,inode 号即索引节点号)才是文件的唯一标识而非文件名。
文件名仅是为了方便人们的记忆和使用。系统或程序通过 inode 号寻找正确的文件数据块。下图展示了程序通过文件名获取文件内容的过程:
要解释清楚两者的区别和联系需要先说清楚 linux 文件系统中的 inode 这个东西。当划分磁盘分区并格式化的时候,整个分区会被划分为两个部分,即inode区和data block(实际数据放置在数据区域中)。
这个inode即是(目录、档案)文件在一个文件系统中的唯一标识,需要访问这个文件的时候必须先找到并读取这个文件的 inode。 Inode 里面存储了文件的很多重要参数,其中唯一标识称作 Inumber, 其他信息还有创建时间(ctime)、修改时间(mtime) 、文件大小、属主、归属的用户组、读写权限、数据所在block号等信息。
目录文件:记录该目录下的文件名
档案文件:记录实际文件数据
inode本身并不记录文件名,文件名记录在目录文件的block当中。
在 Linux 系统中查看 inode 号可使用命令 stat,使用命令 mv 移动或重命名文件,其结果不影响文件的用户数据及inode 号。
为了解决文件的共享使用。
Linux 系统中有软链接和硬链接两种特殊的“文件”。软链接可以看作是Windows中的快捷方式,可以让你快速链接到目标档案或目录。硬链接则通过文件系统的inode来产生新档名,而不是产生新档案。
硬链接(hard link):假设文件A是文件B的硬链接,则A的目录项中的inode节点号与B的目录项中的inode节点号相同,即一个inode节点对应两个不同的文件名,两个文件名指向同一个文件,A和B对文件系统来说是完全平等的。如果删除了其中一个,对另外一个没有影响。每增加一个文件名,inode节点上的链接数增加一,每删除一个对应的文件名,inode节点上的链接数减一,直到为0,inode节点和对应的数据块被回收。注:文件和文件名是不同的东西,rm A删除的只是A这个文件名,而A对应的数据块(文件)只有在inode节点链接数减少为0的时候才会被系统回收。
软链接(soft link):A是B的软链接(A和B都是文件名),A的目录项中的inode节点号与B的目录项中的inode节点号不相同,A和B指向的是两个不同的inode,继而指向两块不同的数据块。但是A的数据块中存放的只是B的路径名(可以根据这个找到B的目录项)。A和B之间是“主从”关系,如果B被删除了,A仍然存在(因为两个是不同的文件),但指向的是一个无效的链接。
Linux 系统引入了两种链接:硬链接 (hard link) 与软链接(又称符号链接,即 soft link 或 symbolic link)。链接为 Linux 系统解决了文件的共享使用,还带来了隐藏文件路径、增加权限安全及节省存储等好处。若一个 inode 号对应多个文件名,则称这些文件为硬链接。换言之,硬链接就是同一个文件使用了多个别名。 (hard link 就是 file 的一个别名,他们有共同的 inode)。硬链接可由命令 link 或 ln 创建。如下是对文件 oldfile 创建硬链接:
link oldfile newfile
#或者
ln oldfile newfile
由于硬链接是有着相同 inode 号仅文件名不同的文件,因此硬链接存在以下几点特性:
- 文件有相同的 inode 及 data block;
- 只能对已存在的文件进行创建;
- 不能交叉文件系统进行硬链接的创建;
- 不能对目录进行创建,只可对文件创建;
- 删除一个硬链接文件并不影响其他有相同 inode 号的文件。
# ls -li
total 0
// 只能对已存在的文件创建硬连接
# link old.file hard.link
link: cannot create link `hard.link' to `old.file': No such file or directory
# echo "This is an original file" > old.file
# cat old.file
This is an original file
# stat old.file
File: `old.file'
Size: 25 Blocks: 8 IO Block: 4096 regular file
Device: 807h/2055d Inode: 660650 Links: 2
Access: (0644/-rw-r--r--) Uid: ( 0/ root) Gid: ( 0/ root)
...
// 文件有相同的 inode 号以及 data block
# link old.file hard.link | ls -li
total 8
660650 -rw-r--r-- 2 root root 25 Sep 1 17:44 hard.link
660650 -rw-r--r-- 2 root root 25 Sep 1 17:44 old.file
// 不能交叉文件系统
# ln /dev/input/event5 /root/bfile.txt
ln: failed to create hard link `/root/bfile.txt' => `/dev/input/event5':
Invalid cross-device link
// 不能对目录进行创建硬连接
# mkdir -p old.dir/test
# ln old.dir/ hardlink.dir
ln: `old.dir/': hard link not allowed for directory
# ls -iF
660650 hard.link 657948 old.dir/ 660650 old.file
文件 old.file 与 hard.link 有着相同的 inode 号:660650 及文件权限,inode 是随着文件的存在而存在,因此只有当文件存在时才可创建硬链接,即当 inode 存在且链接计数器(link count)不为 0 时。inode 号仅在各文件系统下是唯一的,当 Linux 挂载多个文件系统后将出现 inode 号重复的现象(因此不能交叉文件系统进行硬链接的创建,因为,假如第一个文件系统中的一个文件和第二个文件系统的一个文件有同样的inode号,如果我为第一个文件系统的那个文件创建一个硬链接到第二个文件系统,那么此时第二个文件系统中的这两个文件将有着同样的inode,但是内容却不一样,这明显不符合inode是文件的唯一标识这个原则)。
现 Linux 文件系统中的目录均隐藏了两个个特殊的目录:当前目录(.)与父目录(..)。查看这两个特殊目录的 inode 号可知其实这两目录就是两个硬链接。若系统允许对目录创建硬链接,则会产生目录环。
find 路径 -inum inode号
Hard link建立实际上是dentry项的一个拷贝,它们都指向同一个inode节点。当我们使用write改写file1的内容时,hardlink1的内容也会被改写,因为实际上它们是同一个文件。
如下图所示,hardlink1是file1的一个hard link。它们都指向同一个inode1节点。Inode1中有一个计数器,用于记录有几个dentry项指向它。删除任意一个dentry项都不会导致inode1的删除。只有所有指向inode1的dentry都被删除了,inode1才会被删除。
他们实际从某种意义上讲,所有dentry项都是hard link。
软链接与硬链接不同,若文件用户数据块中存放的内容是另一文件的路径名的指向,则该文件就是软连接。软链接也是一个普通文件,只是数据块内容有点特殊。软链接有着自己的 inode 号以及用户数据块。因此软链接的创建与使用没有类似硬链接的诸多限制:
- 软链接有自己的文件属性及权限等;
- 可对不存在的文件或目录创建软链接;
- 软链接可交叉文件系统;
- 软链接可对文件或目录创建;
- 创建软链接时,链接计数 i_nlink 不会增加;
- 删除软链接并不影响被指向的文件,但若被指向的原文件被删除,则相关软链接被称为死链接(即 dangling link,若被指向路径文件被重新创建,死链接可恢复为正常的软链接)
# ls -li
total 0
// 可对不存在的文件创建软链接
# ln -s old.file soft.link
# ls -liF
total 0
789467 lrwxrwxrwx 1 root root 8 Sep 1 18:00 soft.link -> old.file
// 由于被指向的文件不存在,此时的软链接 soft.link 就是死链接
# cat soft.link
cat: soft.link: No such file or directory
// 创建被指向的文件 old.file,soft.link 恢复成正常的软链接
# echo "This is an original file_A" >> old.file
# cat soft.link
This is an original file_A
// 对不存在的目录创建软链接
# ln -s old.dir soft.link.dir
# mkdir -p old.dir/test
# tree . -F --inodes
.
├── [ 789497] old.dir/
│ └── [ 789498] test/
├── [ 789495] old.file
├── [ 789495] soft.link -> old.file
└── [ 789497] soft.link.dir -> old.dir/
软链接创建时原文件的路径指向使用绝对路径较好。使用相对路径创建的软链接被移动后该软链接文件将成为一个死链接。
// 查找在路径 /home 下的文件 data.txt 的软链接
# find /home -lname data.txt
/home/harris/debug/test2/a
1、硬链接,原文件/链接文件公用一个inode号,说明他们是同一个文件,而软链接,原文件/链接文件拥有不同的inode号,表明他们是两个不同的文件。 2、在文件属性上软链接明确写出了是链接文件,而硬链接没有写出来,因为在本质上硬链接文件和原文件是完全平等关系。 3、链接数目是不一样的,软链接的链接数目不会增加。 4、文件大小是不一样的,硬链接文件显示的大小与原文件是一样的。而软链接文件显示的大小与原文件就不一定相同了。
5、软链接没有任何文件系统的限制,任何用户可以创建指向目录的符号链接。
总之,建立软链接就是建立了一个新文件。当访问链接文件时,系统就会发现他是个链接文件,它读取链接文件找到真正要访问的文件。
当然软链接也有硬链接没有的缺点:因为链接文件包含有原文件的路径信息,所以当原文件从一个目录下移到其他目录中,再访问链接文件,系统就找不到了,而硬链接就没有这个缺陷,你想怎么移就怎么移;还有它要系统分配额外的空间用于建立新的索引节点和保存原文件的路径。
Linux 有着极其丰富的文件系统,大体上可分如下几类:
1、网络文件系统,如 nfs、cifs 等;
2、磁盘文件系统,如 ext4、ext3 等;
3、特殊文件系统,如 proc、sysfs、ramfs、tmpfs 等。
实现以上这些文件系统并在 Linux 下共存的基础就是 Linux VFS(Virtual File System 又称 Virtual Filesystem Switch),即虚拟文件系统。VFS 作为一个通用的文件系统,抽象了文件系统的四个基本概念:文件、目录项 (dentry)、索引节点 (inode) 及挂载点,其在内核中为用户空间层的文件系统提供了相关的接口。VFS 实现了 open()、read() 等系统调并使得 cp 等用户空间程序可跨文件系统。VFS 真正实现了上述内容中:在 Linux 中除进程之外一切皆是文件。
Linux VFS 存在四个基本对象:超级块对象 (superblock object)、索引节点对象 (inode object)、目录项对象 (dentry object) 及文件对象 (file object)。超级块对象代表一个已安装的文件系统;索引节点对象代表一个文件(一个文件是由索引号唯一标识的);目录项对象代表一个目录项,如设备文件 event5 在路径 /dev/input/event5 中,其存在四个目录项对象:/ 、dev/ 、input/ 及 event5。文件对象代表由进程打开的文件。
在 Linux 中,索引节点结构存在于系统内存及磁盘,其可区分成 VFS inode 与实际文件系统的 inode。VFS inode 作为实际文件系统中 inode 的抽象,定义了结构体 inode 与其相关的操作 inode_operations(见内核源码 include/linux/fs.h)。
struct inode {
...
const struct inode_operations *i_op; // 索引节点操作
unsigned long i_ino; // 索引节点号
atomic_t i_count; // 引用计数器
unsigned int i_nlink; // 硬链接数目
...
}
struct inode_operations {
...
int (*create) (struct inode *,struct dentry *,int, struct nameidata *);
int (*link) (struct dentry *,struct inode *,struct dentry *);
int (*unlink) (struct inode *,struct dentry *);
int (*symlink) (struct inode *,struct dentry *,const char *);
int (*mkdir) (struct inode *,struct dentry *,int);
int (*rmdir) (struct inode *,struct dentry *);
...
}
每个文件存在两个计数器:i_count 与 i_nlink,即引用计数与硬链接计数。结构体 inode 中的 i_count 用于跟踪文件被访问的数量,而 i_nlink 则是上述使用 ls -l 等命令查看到的文件硬链接数。或者说 i_count 跟踪文件在内存中的情况,而 i_nlink 则是磁盘计数器。当文件被删除时,则 i_nlink 先被设置成 0。文件的这两个计数器使得 Linux 系统升级或程序更新变的容易。系统或程序可在不关闭的情况下(即文件 i_count 不为 0),将新文件以同样的文件名进行替换,新文件有自己的 inode 及 data block,旧文件会在相关进程关闭后被完整的删除。
cfs定义了一种新的模型,它给cfs_rq(cfs的run queue)中的每一个进程安排一个虚拟时钟,vruntime。如果一个进程得以执行,随着时间的增长(也就是一个个tick的到来),其vruntime将不断增大。没有得到执行的进程vruntime不变。 而调度器总是选择vruntime跑得最慢的那个进程来执行。这就是所谓的“完全公平”。为了区别不同优先级的进程,优先级高的进程vruntime增长得慢,以至于它可能得到更多的运行机会。
CFS思路很简单,就是根据各个进程的权重分配运行时间。
进程的运行时间计算公式为:
分配给进程的运行时间 = 调度周期 * 进程权重 / 所有进程权重之和 (公式1)
调度周期很好理解,就是将所有处于TASK_RUNNING态进程都调度一遍的时间,差不多相当于O(1)调度算法中运行队列和过期队列切换一次的时间(对O(1)调度算法看得不是很熟,如有错误还望各位大虾指出)。举个例子,比如只有两个进程A, B,权重分别为1和2,调度周期设为30ms,那么分配给A的CPU时间为:30ms * (1/(1+2)) = 10ms;而B的CPU时间为:30ms * (2/(1+2)) = 20ms。那么在这30ms中A将运行10ms,B将运行20ms。 公平怎么体现呢?它们的运行时间并不一样啊? 其实公平是体现在另外一个量上面,叫做virtual runtime(vruntime),它记录着进程已经运行的时间,但是并不是直接记录,而是要根据进程的权重将运行时间放大或者缩小一个比例。 我们来看下从实际运行时间到vruntime的换算公式 vruntime = 实际运行时间 * 1024 / 进程权重 。 (公式2) 为了不把大家搞晕,这里我直接写1024,实际上它等于nice为0的进程的权重,代码中是NICE_0_LOAD。也就是说,所有进程都以nice为0的进程的权重1024作为基准,计算自己的vruntime增加速度。还以上面AB两个进程为例,B的权重是A的2倍,那么B的vruntime增加速度只有A的一半。现在我们把公式2中的实际运行时间用公式1来替换,可以得到这么一个结果:
vruntime = (调度周期 * 进程权重 / 所有进程总权重) * 1024 / 进程权重 = 调度周期 * 1024 / 所有进程总权重
看出什么眉目没有?没错,虽然进程的权重不同,但是它们的 vruntime增长速度应该是一样的 ,与权重无关。好,既然所有进程的vruntime增长速度宏观上看应该是同时推进的, 那么就可以用这个vruntime来选择运行的进程,谁的vruntime值较小就说明它以前占用cpu的时间较短,受到了“不公平”对待,因此下一个运行进程就是它。这样既能公平选择进程,又能保证高优先级进程获得较多的运行时间。这就是CFS的主要思想了。
或者可以这么理解:CFS的思想就是让每个调度实体(没有组调度的情形下就是进程,以后就说进程了)的vruntime互相追赶,而每个调度实体的vruntime增加速度不同,权重越大的增加的越慢,这样就能获得更多的cpu执行时间。
理想状态下每个进程都能获得相同的时间片,并且同时运行在CPU上,但实际上一个CPU同一时刻运行的进程只能有一个。也就是说,当一个进程占用CPU时,其他进程就必须等待。CFS为了实现公平,必须惩罚当前正在运行的进程,以使那些正在等待的进程下次被调度。
具体实现时,CFS通过每个进程的虚拟运行时间(vruntime)来衡量哪个进程最值得被调度。CFS中的就绪队列是一棵以vruntime为键值的红黑树,虚拟时间越小的进程越靠近整个红黑树的最左端。因此,调度器每次选择位于红黑树最左端的那个进程,该进程的vruntime最小。
虚拟运行时间是通过进程的实际运行时间和进程的权重(weight)计算出来的。在CFS调度器中,将进程优先级这个概念弱化,而是强调进程的权重。一个进程的权重越大,则说明这个进程更需要运行,因此它的虚拟运行时间就越小,这样被调度的机会就越大。
那么,在用户态进程的优先级nice值与CFS调度器中的权重又有什么关系?在内核中通过prio_to_weight数组进行nice值和权重的转换。
而在内核中,进程的虚拟运行时间是自进程诞生以来进行累加的,每个时钟周期内一个进程的虚拟运行时间是通过下面的方法计算的:
一次调度间隔的虚拟运行时间 = 实际运行时间 *(NICE0LOAD / 权重)
其中,NICE_0_LOAD
是nice为0时的权重。也就是说,nice值为0的进程实际运行时间和虚拟运行时间相同。通过这个公式可以看到,权重越大的进程获得的虚拟运行时间越小,那么它将被调度器所调度的机会就越大。
假如新进程的vruntime初值为0的话,比老进程的值小很多,那么它在相当长的时间内都会保持抢占CPU的优势,老进程就要饿死了,这显然是不公平的。所以CFS是这样做的:每个CPU的运行队列cfs_rq都维护一个min_vruntime字段,记录该运行队列中所有进程的vruntime最小值,新进程的初始vruntime值就以它所在运行队列的min_vruntime为基础来设置,与老进程保持在合理的差距范围内。
新进程的vruntime初值的设置与两个参数有关:
sched_child_runs_first:规定fork之后让子进程先于父进程运行;
sched_features的START_DEBIT位:规定新进程的第一次运行要有延迟。
注:
sched_features是控制调度器特性的开关,每个bit表示调度器的一个特性。在sched_features.h文件中记录了全部的特性。START_DEBIT是其中之一,如果打开这个特性,表示给新进程的vruntime初始值要设置得比默认值更大一些,这样会推迟它的运行时间,以防进程通过不停的fork来获得cpu时间片。
如果参数 sched_child_runs_first打开,意味着创建子进程后,保证子进程会在父进程之前运行。
子进程在创建时,vruntime初值首先被设置为min_vruntime;然后,如果sched_features中设置了START_DEBIT位,vruntime会在min_vruntime的基础上再增大一些。设置完子进程的vruntime之后,检查sched_child_runs_first参数,如果为1的话,就比较父进程和子进程的vruntime,若是父进程的vruntime更小,就对换父、子进程的vruntime,这样就保证了子进程会在父进程之前运行。
如果休眠进程的 vruntime 保持不变,而其他运行进程的 vruntime 一直在推进,那么等到休眠进程终于唤醒的时候,它的vruntime比别人小很多,会使它获得长时间抢占CPU的优势,其他进程就要饿死了。这显然是另一种形式的不公平。CFS是这样做的:在休眠进程被唤醒时重新设置vruntime值,以min_vruntime值为基础,给予一定的补偿,但不能补偿太多。
这是由CFS的唤醒抢占特性决定的,即sched_features的WAKEUP_PREEMPT位。
由于休眠进程在唤醒时会获得vruntime的补偿,所以它在醒来的时候有能力抢占CPU是大概率事件,这也是CFS调度算法的本意,即保证交互式进程的响应速度,因为交互式进程等待用户输入会频繁休眠。除了交互式进程以外,主动休眠的进程同样也会在唤醒时获得补偿,例如通过调用sleep()、nanosleep()的方式,定时醒来完成特定任务,这类进程往往并不要求快速响应,但是CFS不会把它们与交互式进程区分开来,它们同样也会在每次唤醒时获得vruntime补偿,这有可能会导致其它更重要的应用进程被抢占,有损整体性能。
假设有两个进程,它们的vruntime初值都是一样的,第一个进程只要一运行,它的vruntime马上就比第二个进程更大了,那么它的CPU会立即被第二个进程抢占吗?答案是这样的:为了避免过于短暂的进程切换造成太大的消耗,CFS设定了进程占用CPU的最小时间值,sched_min_granularity_ns,正在CPU上运行的进程如果不足这个时间是不可以被调离CPU的。
sched_min_granularity_ns发挥作用的另一个场景是,CFS把调度周期sched_latency按照进程的数量平分,给每个进程平均分配CPU时间片(当然要按照nice值加权,为简化起见不再强调),但是如果进程数量太多的话,就会造成CPU时间片太小,如果小于sched_min_granularity_ns的话就以sched_min_granularity_ns为准;而调度周期也随之不再遵守sched_latency_ns,而是以 (sched_min_granularity_ns * 进程数量) 的乘积为准。
索引结构指一个文件的信息存放在若干不连续的物理块中,系统为每个文件建立一个专用的数据结构——索引表,并将这些块的块号存放在索引表中。优点是既能顺序存取,又能随机存取,满足了文件动态增长,插入删除的需求,也能充分利用外存空间。缺点是索引表本身带来的系统开销。
为了提高文件的检索效率,可以采用索引方法组织文件。采用索引这种结构,逻辑上连续的文件可以存放在若干不连续的物理块中,但对于每个文件,在存储介质中除存储文件本身外,还要求系统另外建立一张索引表,索引表记录了文件信息所在的逻辑块号和与之对应的物理块号。索引表也以文件的形式存储在存储介质中,索引表的物理地址则由文件说明信息项给出。
在很多情况下,有的文件很大,文件索引表也就较大。如果索引表的大小超过了一个物理块,可以采用间接索引(多重索引),也就是在索引表所指的物理块中存放的不是文件信息,而是装有这些信息的物理块地址。这样,如果一个物理块可装下n个物理块地址,则经过一级间接索引,可寻址的文件长度将变为n×n块。如果文件长度还大于n×n块,还可以进行类似的扩充,即二级间接索引。
不过,大多数文件不需要进行多重索引,也就是说,这些文件所占用的物理块的所有块号可以放在一个物理块内。如果对这些文件也采用多重索引,则显然会降低文件的存取速度。因此,在实际系统中,总是把索引表的头几项设计成直接寻址方式,也就是这几项所指的物理块中存放的是文件信息;而索引表的后几项设计成多重索引,也就是间接寻址方式。在文件较短时,就可利用直接寻址方式找到物理块号而节省存取时间。
索引结构既适用于顺序存取,也适用于随机存取,并且访问速度快,文件长度可以动态变化。索引结构的缺点是由于使用了索引表而增加了存储空间的开销。另外,在存取文件时需要至少访问存储器两次以上,其中,一次是访问索引表,另一次是根据索引表提供的物理块号访问文件信息。由于文件在存储设备的访问速度较慢,因此,如果把索引表放在存储设备上,势必大大降低文件的存取速度。一种改进的方法是,当对某个文件进行操作之前,系统预先把索引表放入内存,这样,文件的存取就可直接在内存通过索引表确定物理地址块号,而访问存储设备的动作只需要一次。当文件被打开时,为提高访问速度将索引表读入内存,故又需要占用额外的内存空间。
Reactor这个词译成汉语还真没有什么合适的,很多地方叫反应器模式,但更多好像就直接叫reactor模式了,其实我觉着叫应答者模式更好理解一些。通过了解,这个模式更像一个侍卫,一直在等待你的召唤,或者叫召唤兽。
并发系统常使用reactor模式,代替常用的多线程的处理方式,节省系统的资源,提高系统的吞吐量。
先用比较直观的方式来介绍一下这种方式的优点,通过和常用的多线程方式比较一下,可能更好理解。
以一个餐饮为例,每一个人来就餐就是一个事件,他会先看一下菜单,然后点餐。就像一个网站会有很多的请求,要求服务器做一些事情。处理这些就餐事件的就需要我们的服务人员了。
在多线程处理的方式会是这样的:
一个人来就餐,一个服务员去服务,然后客人会看菜单,点菜。 服务员将菜单给后厨。
二个人来就餐,二个服务员去服务……
五个人来就餐,五个服务员去服务……
这个就是多线程的处理方式,一个事件到来,就会有一个线程服务。很显然这种方式在人少的情况下会有很好的用户体验,每个客人都感觉自己是VIP,专人服务的。如果餐厅一直这样同一时间最多来5个客人,这家餐厅是可以很好的服务下去的。
来了一个好消息,因为这家店的服务好,吃饭的人多了起来。同一时间会来10个客人,老板很开心,但是只有5个服务员,这样就不能一对一服务了,有些客人就要没有人管了。老板就又请了5个服务员,现在好了,又能每个人都受VIP待遇了。
越来越多的人对这家餐厅满意,客源又多了,同时来吃饭的人到了20人,老板高兴不起来了,再请服务员吧,占地方不说,还要开工钱,再请人就攒不到钱了。怎么办呢?老板想了想,10个服务员对付20个客人也是能对付过来的,服务员勤快点就好了,伺候完一个客人马上伺候另外一个,还是来得及的。综合考虑了一下,老板决定就使用10个服务人员的线程池啦~~~
但是这样有一个比较严重的缺点就是,如果正在接受服务员服务的客人点菜很慢,其他的客人可能就要等好长时间了。有些火爆脾气的客人可能就等不了走人了。
Reactor如何处理这个问题呢:
老板后来发现,客人点菜比较慢,大部服务员都在等着客人点菜,其实干的活不是太多。老板能当老板当然有点不一样的地方,终于发现了一个新的方法,那就是:当客人点菜的时候,服务员就可以去招呼其他客人了,等客人点好了菜,直接招呼一声“服务员”,马上就有个服务员过去服务。嘿嘿,然后在老板有了这个新的方法之后,就进行了一次裁员,只留了一个服务员!这就是用单个线程来做多线程的事。
实际的餐馆都是用的Reactor模式在服务。一些设计的模型其实都是从生活中来的。
Reactor模式主要是提高系统的吞吐量,在有限的资源下处理更多的事情。
在单核的机上,多线程并不能提高系统的性能,除非在有一些阻塞的情况发生。否则线程切换的开销会使处理的速度变慢。就像你一个人做两件事情,1、削一个苹果。2、切一个西瓜。那你可以一件一件的做,我想你也会一件一件的做。如果这个时候你使用多线程,一会儿削苹果,一会切西瓜,可以相像究竟是哪个速度快。这也就是说为什么在单核机上多线程来处理可能会更慢。
但当有阻碍操作发生时,多线程的优势才会显示出来,现在你有另外两件事情去做,1、削一个苹果。2、烧一壶开水。我想没有人会去做完一件再做另一件,你肯定会一边烧水,一边就把苹果削了。
理论的东西就不多讲了,请大家参考一下附件《reactor-siemens.pdf》。图比较多,E文不好也可以看懂的。
子进程可以访问父进程变量。子进程“继承”父进程的数据空间,堆和栈,其地址总是一样的。
因为在fork时整个虚拟地址空间被复制,但是虚拟地址空间所对应的物理内存开始却没有复制(也就是只复制了父进程的页表,没有复制父进程的物理内存中的数据),如果有写入时写时拷贝。比如,这个时候父子进程中变量 x对应的虚拟地址和物理地址都相同,但等到虚拟地址空间被写时,对应的物理内存空间被复制,这个时候父子进程中变量x对应的虚拟地址还是相同的,但是物理地址不同,这就是”写时复制”。还有父进程和子进程是始终共享正文段(代码段)。
(1)管道PIPE和有名管道FIFO – 比如shell的重定向 (2)信号signal – 比如杀死某些进程kill -9,比如忽略某些进程nohup ,信号是一种软件中断 (3)消息队列 – 相比共享内存会慢一些,缓冲区有限制,但不用加锁,适合命令等小块数据。 (4)共享内存 – 最快的IPC方式,同一块物理内存映射到进程A、B各自的进程地址空间,可以看到对方的数据更新,需要注意同步机制,比如互斥锁、信号量。适合传输大量数据。 (5)信号量 – PV操作,生产者与消费者示例; (6)套接字 – socket网络编程,可以在网络中,不同主机之间进行进程间的通信,属高级进程间通信。
(1)互斥锁(mutex) (2)条件变量 (3)信号量。 如同进程一样,线程也可以通过信号量来实现同步,虽然是轻量级的。
从本质上来讲,中断是一种电信号,当设备有某种事件发生时,它就会产生中断,通过总线把电信号发送给中断控制器。 如果中断的线是激活的,中断控制器就把电信号发送给处理器的某个特定引脚。处理器于是立即停止自己正在做的事, 跳到中断处理程序的入口点,进行中断处理。
①硬中断是由外部设备对CPU的中断,具有随机性和突发性;软中断由程序控制,执行中断指令(例如int指令)产生的、无外部施加的中断请求信号,因此不是随机的而是安排好的。如信号就是软件中断,键盘输入、鼠标点击就是硬件中断。 ②硬中断的中断响应周期,CPU需要发中断回合信号(NMI不需要),软中断的中断响应周期,CPU不需发中断回合信号。 ③硬中断的中断号是由中断控制器提供的(NMI硬中断中断号系统指定为02H);软中断的中断号由指令直接给出,无需使用中断控制器。 ④硬中断是可屏蔽的(NMI硬中断不可屏蔽),软中断不可屏蔽。
堆排序和快速排序都是比较类非线性比较类排序中较优的排序方法,均是不稳定排序,且平均时间复杂度均为O(nlogn)。区别有二: (1)堆属于选择类排序,快速排序属于交换类排序; (2)堆排序一般优于快速排序的重要一点是,数据的初始分布情况对堆排序的效率没有大的影响。
a = a ^ b;
b = a ^ b;
a = a ^ b;
根据真实的网络环境,那边可能会接收到不定的字节。
不然会一直无限传值,直到栈溢出。
构造函数私有化
void func(int (&b) [20][10]) {
//通过数组的引用
}
void func(int *b) {
}
int main()
{
//freopen("input.txt","r",stdin);
int a[20][10];
a[2][1] = 5;
func(a);
return 0;
}
传值可以,引用不行。数组传值那边退化为指针
main函数不一定是第一个运行的函数,比如全局类的构造函数。
首先了解什么叫RPC,为什么要RPC,RPC是指远程过程调用,也就是说两台服务器A,B,一个应用部署在A服务器上,想要调用B服务器上应用提供的函数/方法,由于不在一个内存空间,不能直接调用,需要通过网络来表达调用的语义和传达调用的数据。
一个进程可以监听多个端口(但是一个端口只能有一个进程监听)。 nginx可以配置多个server,每个server监听不同的端口。 php-fpm可以配置多个pool,每个pool监听不同的端口。 httpd可以用Listen监听多个端口(Listen监听了这些端口,VirtualHost里才能使用这些端口)。
TCP 报文段的报头有 10 个必需的字段(即源端口、目的端口这些)和 1 个可选字段(即长度可变的那个选项)。报头至少为 20 字节。报头后面的数据是可选项。
标识发送报文的计算机端口或进程。一个 TCP 报文段必须包括源端口号,使目的主机知道应该向何处发送确认报文。
标识接收报文的目的主机的端口或进程。
用于标识每个报文段,使目的主机可确认已收到指定报文段中的数据。当源主机用于多个报文段发送一个报文时,即使这些报文段到达目的主机的顺序不一样,序列号也可以使目的主机按顺序排列它们。
在 SYN 标志未置位时,该字段指示了用户数据区中第一个字节的序号;在 SYN 标志置位时,该字段指示的是初始发送的序列号。
在建立连接时发送的第一个报文段中(此报文段不包含数据),双方都提供一个初始序列号。TCP 标准推荐使用以 4ms 间隔递增 1 的计数器值作为这个初始序列号的值。使用计数器可以防止连接关闭再重新连接时出现相同的序列号。
对于那些包含数据的报文段,报文段中第一个数据字节的数量就是初始序列号,其后数据字节按顺序编号。如果源主机使用同样的连接发送另一个报文段,那么这个报文段的序列号等于前一个报文段的序列号与前一个报文段中数据字节的数量之和。例如,假设源主机发送 3 个报文段,每个报文段有 100 字节的数据,且第一个报文段的序列号是 1000,那么第二个报文段的序列号就是 1100(1000 + 100),第三个报文段的序列号就是 1200(1100 + 100)。
如果序列号增大至最大值将复位为 0。
目的主机返回确认号,使源主机知道某个或几个报文段已被接收。如果 ACK 控制位被设置为 1,则该字段有效。确认号等于顺序接收到的最后一个报文段的序号加 1,这也是目的主机希望下次接收的报文段的序号值。返回确认号后,计算机认为已接收到小于该确认号的所有数据。
例如,序列号等于前一个报文段的序列号与前一个报文段中数据字节的数量之和。例如,假设源主机发送 3 个报文段,每个报文段有 100 字节的数据,且第一个报文段的序列号是 1000,那么接收到第一个报文段后,目的主机返回含确认号1100 的报头。接收到第二个报文段(其序号为 1100 )后,目的主机返回确认号 1200。接收到第三个报文段后,目的主机返回确认号 1300 。
目的主机不一定在每次接收到报文段后都返回确认号。在上面的例子中,目的主机可能等到所有 3 个报文段都收到后,再返回一个含确认号 1300 的报文段,表示已接收到全部 1299 字节的数据。但是如果目的主在发回确认号之前等待时间过长,源主机会认为数据没有到达目的主机,并自动重发。
TCP 报文段的数据起始处距离 TCP 报文段的起始处有多远,即首部长度(报头长度)。 由于 TCP 报头的长度随 TCP 选项字段内容的不同而变化,因此报头中包含一个指定报头字段的字段。该字段以 32 比特为单位,所以报头长度一定是 32 比特的整数倍,有时需要在报头末尾补 0 。如果报头没有 TCP 选项字段,则报头长度值为 5 ,表示报头一个有 160 比特,即 20 字节(也就是说至少有20个字节)。
由跟在数据偏移字段后的 6 位构成, 全部为 0
此位置 1,表明紧急指针字段有效,它告诉系统此报文段中有紧急数据,应尽快传送。
仅当 ACK = 1 时确认号字段才有效,TCP 规定,在连接建立后所有传达的报文段都必须把 ACK 置 1。
当两个应用进程进行交互式的通信时,有时在一端的应用进程希望在键入一个命令后立即就能够收到对方的响应。在这种情况下,TCP 就可以使用推送(push)操作,这时,发送方 TCP 把 PSH 置 1 ,并立即创建一个报文段发送出去,接收方收到 PSH = 1 的报文段,就尽快地(即“推送”向前)交付给接收应用进程,而不再等到整个缓存都填满后再向上交付。
用于复位相应的 TCP 连接
仅在三次握手建立 TCP 连接时有效。当 SYN = 1 而 ACK = 0 时,表明这是一个连接请求报文段,对方若同意建立连接,则应在相应的报文段中使用 SYN = 1 和 ACK = 1。因此,SYN 置 1 就表示这是一个连接请求或连接接受报文。
用来释放一个连接。当 FIN = 1 时,表明此报文段的发送方的数据已经发送完毕,并要求释放运输连接。
此字段用来进行流量控制,这个值是本机期望一次接收的字节数,即发送数据的窗口大小。告诉对方在不等待确认的情况下,可以发来多大的数据。这里表示的最大长度是2^16 - 1 = 65535,如需要使用更大的窗口大小,需要使用选项中的窗口扩大因子选项。
指发送本报文段的一方的接收窗口(而不是自己的发送窗口)。
源主机和目的主机根据 TCP 报文段以及伪报头的内容计算校验和。在伪报头中存放着来自 IP 报头以及 TCP 报文段长度信息。与 UDP 一样,伪报头并不在网络中传输,并且在校验和中包含伪报头的目的是防止目的主机错误地接收存在路由错误的数据报。
伪首部, 又称为伪包头(Pseudo Header):是指在 TCP 的分段或 UDP 的数据报格式中,在数据报首部前面增加源 IP 地址、目的 IP 地址、IP 分组的协议字段、TCP 或 UDP 数据报的总长度等共12字节,所构成的扩展首部结构。此伪首部是一个临时的结构,它既不向上也不向下传递,仅仅只是为了保证可以校验套接字的正确性。
仅在 URG = 1 时才有意义,它指出本报文段中的紧急数据的字节数(紧急数据结束后就是普通数据),即指出了紧急数据的末尾在报文中的位置,注意:即使窗口为零时也可发送紧急数据。
如果 URG 为 1 ,则紧急指针标志着紧急数据的结束。其值是紧急数据最后 1 字节的序号,表示报文段序号的偏移量。例如,如果报文段的序号是 1000,前 8 个字节都是紧急数据,那么紧急指针就是 8 。紧急指针一般用途是使用户可中止进程。
可能包括“窗口扩大因子”、“时间戳”等选项。长度可变,最长可达 40 字节,当没有使用选项时,TCP 首部长度是 20 字节。
填充用于保证任选项为 32bit 的整数倍。
TCP 首部结束之后的部分
发送方端口号
接收方端口号
UDP 用户数据报的总长度(注意,不仅仅是UDP首部),以字节为单位
用于校验 UDP 数据报的数据段和 UDP 数据报首部的“伪首部”。检测 UDP 用户数据报在传输中是否有错,有错就丢弃。
UDP 的数据部分如果不为偶数需要用 0 填补,就是说,如果数据长度为奇数,数据长度加“1”。
又称为伪包头(Pseudo Header):是指在 TCP 报文段或 UDP 的数据报格式中,在首部前面增加源 IP 地址、目的 IP 地址、IP 分组的协议字段、TCP 或 UDP 数据报等总长度共12字节的扩展首部结构。此伪首部是一个临时的结构,它既不向上也不向下传递,仅仅只是为了保证可以校验套接字的正确性。
100 Continue 继续,一般在发送post请求时,已发送了http header之后服务端将返回此信息,表示确认,之后发送具体参数信息
200 OK 正常返回信息
201 Created 请求成功并且服务器创建了新的资源
202 Accepted 服务器已接受请求,但尚未处理
301 Moved Permanently 请求的网页已永久移动到新位置。
302 Found 临时性重定向。
303 See Other 临时性重定向,且总是使用 GET 请求新的 URI。
304 Not Modified 自从上次请求后,请求的网页未修改过。
400 Bad Request 服务器无法理解请求的格式,客户端不应当尝试再次使用相同的内容发起请求。
401 Unauthorized 请求未授权。
403 Forbidden 禁止访问。
404 Not Found 找不到如何与 URI 相匹配的资源。
500 Internal Server Error 最常见的服务器端错误。
503 Service Unavailable 服务器端暂时无法处理请求(可能是过载或维护)。
一个TCP连接必须要经过三次“对话”才能建立起来,其中的过程非常复杂,只简单的描述下这三次对话的简单过程:主机A向主机B发出连接请求数据包:“我想给你发数据,可以吗?”,这是第一次对话;主机B向主机A发送同意连接和要求同步 (同步就是两台主机一个在发送,一个在接收,协调工作)的数据包:“可以,你什么时候发?”,这是第二次对话;主机A再发出一个数据包确认主机B的要求同 步:“我现在就发,你接着吧!”,这是第三次对话。三次“对话”的目的是使数据包的发送和接收同步,经过三次“对话”之后,主机A才向主机B正式发送数据。
(注意,因为这里还没有开始发送数据,所以 +1 即可)
首先由Client发出请求连接即 SYN=1 ACK=0 (请看头字段的介绍),TCP规定SYN=1时不能携带数据,但要消耗一个序号,因此声明自己的序号是 seq=x。然后 Server 进行回复确认,即 SYN=1 ACK=1 seq=y,ack=x+1,再然后 Client 再进行一次确认,但不用SYN 了,这时即为 ACK=1, seq=x+1,ack=y+1。然后连接建立。
为什么要进行三次握手呢(两次确认)?
建立三次握手主要是因为A发送了再一次的确认,那么A为什么会再确认一次呢,主要是为了防止已失效的连接请求报文段又突然传送给B,从而产生了错误。
所谓“已失效的连接请求报文”是这样产生的,正常情况下,A发出连接请求,但是因为连接报文请求丢失而未收到确认,于是A再重传一次连接请求,后来收到了请求,并收到了确认,建立了连接,数据传输完毕后,就释放链接,A共发送了两次连接请求报文段,其中第一个丢失,第二个到达了B,没有“已失效的连接请求报文段”,但是还有异常情况下,A发送的请求报文连接段并没有丢失,而是在某个网络节点滞留较长时间,以致延误到请求释放后的某个时间到达B,本来是一个早已失效的报文段,但是B收到了此失效连接请求报文段后,就误以为A又重新发送连接请求报文段,并发送确认报文段给A,同意建立连接,如果没有三次握手,那么B发送确认后,连接就建立了,而此时A没有发送建立连接的请求报文段,于是不理会B的确认,也不会给B发送数据,而B却一直等待A发送数据,因此B的许多资源就浪费了,采用三次握手的方式就可以防止这种事情发生,例如刚刚,A不理会B,就不会给B发送确认,B收不到A的确认,就知道A不要求建立连接,就不会白白浪费资源。
(客户端第一次的FIN_WAIT是在等待服务器的确认,客户端第二次的FIN_WAIT是在等待B请求释放连接)
当客户A 没有东西要发送时就要释放 A 这边的连接,A会发送一个报文(没有数据),其中 FIN 设置为1, 服务器B收到后会给应用程序一个回应,这时A那边的连接已经关闭,即A不再发送信息(但仍可接收信息)。 A收到B的确认后进入等待状态,等待B请求释放连接, B数据发送完成后就向A请求连接释放,也是用FIN=1 表示, 并且用 ack = u+1(如图), A收到后回复一个确认信息,并进入 TIME_WAIT 状态, 等待 2MSL 时间。
为什么要等待2MSL 时间呢?
为了这种情况: B向A发送 FIN = 1 的释放连接请求,但这个报文丢失了, A没有接到不会发送确认信息, B 超时会重传,这时A在 WAIT_TIME 还能够接收到这个请求,这时再回复一个确认就行了。(A收到 FIN = 1 的请求后 WAIT_TIME会重新记时)。
另外服务器B存在一个保活状态,即如果A突然故障死机了,那B那边的连接资源什么时候能释放呢? 就是保活时间到了后,B会发送探测信息, 以决定是否释放连接。
为什么连接的时候是三次握手,关闭的时候却是四次握手?
总结起来就是因为关闭的时候存在半连接状态。
因为当Server端收到Client端的SYN连接请求报文后,可以直接发送SYN+ACK报文(也就是说Server不存在“等一下,我还没有准备好连接,等我准备好了我会给你发一个SYN”这样的情况)。其中ACK报文是用来应答的,SYN报文是用来同步的。但是关闭连接时,当Server端收到FIN报文时,很可能并不会立即关闭SOCKET,所以只能先回复一个ACK报文,告诉Client端,"你发的FIN报文我收到了"。只有等到我Server端所有的报文都发送完了,我才能发送FIN报文,因此不能一起发送。故需要四步握手。
1、应用数据被分割成TCP认为最适合发送的数据块。 2、超时重传:当TCP发出一个段后,它启动一个定时器,等待目的端确认收到这个报文段。如果不能及时收到一个确认,将重发这个报文段。 3、TCP给发送的每一个包进行编号,接收方对数据包进行排序,把有序数据传送给应用层。 4、校验和:TCP将保持它首部和数据的检验和。这是一个端到端的检验和,目的是检测数据在传输过程中的任何变化。如果收到段的检验和有差错,TCP将丢弃这个报文段和不确认收到此报文段。 5、TCP的接收端会丢弃重复的数据。 6、流量控制:TCP连接的每一方都有固定大小的缓冲空间,TCP的接收端只允许发送端发送接收端缓冲区能接纳的数据。当接收方来不及处理发送方的数据,能提示发送方降低发送的速率,防止包丢失。TCP使用的流量控制协议是可变大小的滑动窗口协议。 7、拥塞控制:当网络拥塞时,减少数据的发送。
数据库事务是指作为单个逻辑工作单元执行的一系列操作,这些操作要么全做要么全不做,是一个不可分割的工作单位。
指的是,事务中包含的程序作为数据库的逻辑工作单位,它所做的对数据修改操作要么全部执行,要么完全不执行。这种特性称为原子性。
例如银行取款事务分为2个步骤(1)存折减款(2)提取现金。不可能存折减款,却没有提取现金。2个步骤必须同时完成或者都不完成。
事务的一致性指的是在一个事务执行之前和执行之后数据库都必须处于一致性状态。这种特性称为事务的一致性。假如数据库的状态满足所有的完整性约束,就说该数据库是一致的。
例如完整性约束a+b=10,一个事务改变了a,那么b也应随之改变。
分离性指并发的事务是相互隔离的。即一个事务内部的操作及正在操作的数据必须封锁起来,不被其它企图进行修改的事务看到。假如并发交叉执行的事务没有任何控制,操纵相同的共享对象的多个并发事务的执行可能引起异常情况。
持久性意味着当系统或介质发生故障时,确保已提交事务的更新不能丢失。即一旦一个事务提交,DBMS保证它对数据库中数据的改变应该是永久性的,即对已提交事务的更新能恢复。持久性通过数据库备份和恢复来保证。
1、该进程是僵尸进程。(可以把该进程的父进程杀死,然后该子进程被init进程收留,然后该僵尸进程被init进程清理)
2、进入内核态,忽略kill信号。
按照预先规定的大小,将分配的内存分割成特定长度的块,以完全解决内存碎片问题。整理内存以便重复使用。
首先,我们要明白Hash的意义在于把一个大的集合A,映射到小的集合B。比如通过取余的方式进行Hash,集合A = {0, 1, …, M, M+1, …, 2M, 2M+1, …, 3M, 3M+1, …}就会被映射到集合B = {0, 1, 2, …, M - 1}。
然后,如果集合A的元素分布是{0, 1, 2, 3, 4, …}这样的话,M取质数还是合数都可以,最后整个集合A会被均匀地映射到{0, 1, …, M-1}。
(例子)但是,很多情况下我们的元素分布是有非1步长的,比如集合A = {0, 6, 12, 18, 24, 30, 36, …},这时候就出现问题了。当M取合数时,比如M = 10,我们来看看映射的情况。0->0, 6->6, 12->2, 18->8, 24->4, 30->0, 36->6, …。此时我们很容易发现,最后映射到了集合B = {0, 6, 2, 8, 4} = {0, 2, 4, 6, 8},和我们理想中的{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}差距很大啊。(可以看出使用取余的方式这是不平衡的)
有问题我们就要去解决啊。我们回到代数形式上来看,在N与M最大公因数为1的情况下,N % M后可以得到的一系列的余数r,而全体余数r的集合R就是{0, 1, 2, …, M-1}。每个余数r代表了一个N的集合,比如在上面的例子中余数0代表了N的集合{0, 10, 20, 30, …},这个集合就叫做“同余类”。即余数集合R中有M个余数,每个余数r代表一个同余类。现在思路就很清晰了,我们最理想的方式就是将集合A映射到余数集合R上,即B = R。
接下来我们讨论一下为什么有时候无法完全利用余数集合R:
假设N = kn, M = km, N和M存在最大公因数k,此时可以将N % M = r转化为公式N = Mq + r,即kn = kmq + r。其中q是商,r是余数。**“表面上”r的取值范围是{0, 1, 2, …, M-1}(忽视了只有N与M最大公因数为1时,才能取整个余数集合R的定理),一片和谐。但是可以对公式进行稍微的变换,n = mq + (r/k),由于n和mq都是整数,则(r/k)也是整数。此时我们看一看到(r/k)的取值范围是{0, 1, 2, …, M/k} = {0, 1, 2, …, m}。恢复到原式,也是就r的“实际”**取值范围是{0, k, 2k, 3k, …, m*k},缩小了k倍。
一切都明了了,我们最后的目标就是保证N与M最大公因数为1。最简单的方式就是直接取M为质数!
主机根据ip地址和虚拟机的编号 host_ip#id生成hash值,排列成环。 需要缓存的对象也用同样的hash算法生成hash值,排列成环。 然后对象存储在顺时针离它最近的主机上。
单调性体现在: 无论是新增主机还是删除主机,需要改变位置的都是离那台主机最近的那些节点,其他节点不需要改变位置。
MMU是Memory Management Unit的缩写,中文名是内存管理单元,它是中央处理器(CPU)中用来管理虚拟存储器、物理存储器的控制线路,同时也负责虚拟地址映射为物理地址,以及提供硬件机制的内存访问授权,多用户多进程操作系统。
将逻辑地址转换到虚拟地址(虚拟地址又称为线性地址),需要用到段式MMU。,将虚拟地址转换到物理地址,需要用到页式MMU。(即通过MMU把逻辑地址转换为虚拟地址再转换为物理地址,即逻辑地址到物理地址的转换中间隔了一个虚拟地址)
虚拟存储器的基本思想是程序,数据,堆栈的总的大小可以超过物理存储器的大小,操作系统把当前使用的部分保留在内存中,而把其他未被使用的部分保存在磁盘上。比如对一个16MB的程序和一个内存只有4MB的机器,操作系统通过选择,可以决定各个时刻将哪4M的内容保留在内存中,并在需要时在内存和磁盘间交换程序片段,这样就可以把这个16M的程序运行在一个只具有4M内存机器上了。而这个16M的程序在运行前不必由程序员进行分割。
任何时候,计算机上都存在一个程序能够产生的地址集合,我们称之为地址范围。这个范围的大小由CPU的位数决定,例如一个32位的CPU,它的地址范围是00xFFFFFFFF (4G),而对于一个64位的CPU,它的地址范围为00xFFFFFFFFFFFFFFFF (16E).这个范围就是我们的程序能够产生的地址范围,我们把这个地址范围称为虚拟地址空间,该空间中的某一个地址我们称之为虚拟地址。与虚拟地址空间和虚拟地址相对应的则是物理地址空间和物理地址,大多数时候我们的系统所具备的物理地址空间只是虚拟地址空间的一个子集。这里举一个最简单的例子直观地说明这两者,对于一台内存为256M的32bit x86主机来说,它的虚拟地址空间范围是00xFFFFFFFF(4G),而物理地址空间范围是0x000000000x0FFFFFFF(256M)。
在没有使用虚拟存储器的机器上,地址被直接送到内存总线上,使具有相同地址的物理存储器被读写;而在使用了虚拟存储器的情况下,虚拟地址不是被直接送到内存地址总线上,而是送到存储器管理单元MMU,把虚拟地址映射为物理地址。
大多数使用虚拟存储器的系统都使用一种称为分页(paging)机制。虚拟地址空间划分成称为页(page)的单位,而相应的物理地址空间也被进行划分,单位是页帧(frame)。页和页帧的大小必须相同。在这个例子中我们有一台可以生成32位地址的机器,它的虚拟地址范围从0~0xFFFFFFFF(4G),而这台机器只有256M的物理地址,因此他可以运行4G的程序,但该程序不能一次性调入内存运行。这台机器必须有一个达到可以存放4G程序的外部存储器(例如磁盘或是FLASH),以保证程序片段在需要时可以被调用。在这个例子中,页的大小为4K,页帧大小与页相同——这点是必须保证的,因为内存和外围存储器之间的传输总是以页为单位的。对应4G的虚拟地址和256M的物理存储器,他们分别包含了1M个页和64K个页帧。
如果CPU寄存器中的分页标志位被设置,那么执行内存操作的机器指令时,CPU(准确来说,是MMU,即Memory Management Unit,内存管理单元。MMU在CPU里面)会自动根据页目录和页表中的信息,把虚拟地址转换成物理地址,完成该指令。
比如 mov eax,[004227b8h] ,这是把地址004227b8h处的值赋给寄存器的汇编代码,004227b8这个地址就是虚拟址。CPU在执行这行代码时,发现寄存器中的分页标志位已经被设定,就自动完成虚拟地址到物理地址的转换,使用物理地址取出值,完成指令。
使用了分页机制之后,4G的地址空间被分成了固定大小的页,每一页或者被映射到物理内存,或者被映射到硬盘上的交换文件中,或者没有映射任何东西。对于一般程序来说,4G的地址空间,只有一小部分映射了物理内存,大片大片的部分是没有映射任何东西。物理内存也被分页,来映射地址空间。对于32bit的Win2k,页的大小是4K字节。CPU(MMU)用来把虚拟地址转换成物理地址的信息存放在叫做页目录和页表的结构里(也就是说MMU是利用了进程中的页表来完成虚拟地址到物理地址的转换)。
对于一个要转换成物理地址的虚拟地址,CPU(MMU)首先根据CR3中的值,找到页目录所在的物理页。然后根据虚拟地址的第22位到第31位这10位(最高的10bit)的值作为索引,找到相应的页目录项(PDE,page directory entry),页目录项中有这个虚拟地址所对应页表的物理地址。有了页表的物理地址,根据虚拟地址的第12位到第21位这10位的值作为索引,找到该页表中相应的页表项(PTE,page table entry),页表项中就有这个虚拟地址所对应物理页的物理地址。最后用虚拟地址的最低12位,也就是页内偏移,加上这个物理页的物理地址,就得到了该虚拟地址所对应的物理地址。(页表和页表项都是通过虚拟地址的某几位来找到的)
32bit的一个指针,可以寻址范围0x00000000-0xFFFFFFFF,4GB大小。也就是说一个32bit的指针可以寻址整个4GB地址空间的每一个字节。一个页表项(即page结构体)负责4K的地址空间和物理内存的映射,一个页表1024个页表项,也就是负责1024 * 4k=4M的地址空间的映射。一个页目录项,对应一个页表。一个页目录有1024项,也就对应着1024个页表,每个页表负责4M地址空间的映射。1024个页表负责1024 * 4M=4G的地址空间映射。一个进程有一个页目录。所以以页为单位,页目录和页表可以保证4G的地址空间中的每页和物理内存的映射。
进程的页之所以可以在内存中不连续分布,是因为有页表的存在,拿到一个虚拟地址,通过虚拟地址的前几位查页表项得到内存中的页框号,连接偏移量就可以得到物理地址,因此进程页连不连续无所谓。
问题描述:有N(N>>10000)个整数,求出其中的前K个最大的数。(称作Top k或者Top 10)
**问题分析:**由于(1)输入的(这是一个动态添加的过程,所以不应该用快速排序,而是动态的去维护二叉堆,时间复杂度是logn)大量数据;(2)只要前K个,对整个输入数据的保存和排序是相当的不可取的。
可以考虑使用数据结构的最小堆来处理该问题。
AVL树和红黑树都是用旋转保持平衡
AVL树对每个插入操作最多需要两次旋转(单/双旋),对每个删除操作最多需要O(logn)次旋转;而红黑树对每个插入和删除操作,任何不平衡都会在三次旋转之内解决。
查找、插入、删除的时间均为log(n),红黑树的算法时间复杂度和AVL相同,但红黑树的统计性能要好于平衡二叉树,但极端性能略差。
平衡因子
某结点的左子树与右子树的高度(深度)差即为该结点的平衡因子。
插入新节点,平衡操作:
最小不平衡子树的BF与它的子树的BF符号相同时:
- 最小不平衡子树根结点的平衡因子大于1时(即左子树高度 - 右子树高度 > 1),右旋
- 最小不平衡子树根结点的平衡因子小于-1时((即左子树高度 - 右子树高度 < -1)),左旋
最小不平衡子树的BF与它的子树的BF符号相反时:
- 先对子树结点进行一次旋转使得符号相同,再反向旋转一次才能完成平衡操作。
删除结点:
删除一个结点q后,从根结点到q的路径上的一些结点或者全部结点的平衡因子都改变了,删除操作最多需要O(logn)次旋转。
满足下列条件的二叉搜索树是红黑树
性质1. 节点是红色或黑色 性质2. 根是黑色 性质3. 所有叶子都是黑色(叶子是Null节点) 性质4. 如果一个节点是红的,则它的两个儿子都是黑的 性质5. 从任一节点到其叶子的所有简单路径都包含相同数目的黑色节点。
这五个性质强制了红黑树的关键性质: 从根到叶子的最长的可能路径不多于最短的可能路径的两倍长。为什么呢?性质4暗示着任何一个简单路径上不能有两个毗连的红色节点,这样,最短的可能路径全是黑色节点,最长的可能路径有交替的红色和黑色节点。同时根据性质5知道:所有最长的路径都有相同数目的黑色节点,这就表明了没有路径能多于任何其他路径的两倍长。
规则5要求新增结点为红结点,规则4要求新节点的父节点为黑色,当新节点到达插入点,却未能符合上述5个条件,需要调整颜色并旋转树形。
红黑树并不是平衡二叉树,恰恰相反,红黑树放松了平衡二叉树的某些要求,由于一定限度的“不平衡”,红黑树的性能得到了提升。
查找、插入、删除的时间均为log(n),统计性能要好于平衡二叉树,但极端性能略差。
STL中set、multiset、map(是一种关联容器,不要翻译成哈希表)、multimap底层是红黑树实现的,而unordered_map、unordered_set 底层是哈希表实现的。
multiset(集合中可以有相同元素)、multimap(map中可以有同名的key): 插入相同的key的时候,我们将后插入的key放在相等的key的右边,之后不管怎么进行插入或删除操作,后加入的key始终被认为比之前的大。
无锁,英文一般翻译为lock-free,是利用处理器的一些特殊的原子指令来避免传统并行设计中对锁的使用。
通信项目一般对性能有极致的追求,这是我们使用无锁的重要原因。当然,无锁算法如果实现的不好,性能可能还不如使用锁,所以我们选择比较擅长的数据结构和算法进行lock-free实现,比如Queue,对于比较复杂的数据结构和算法我们通过lock来控制,比如Map(虽然我们实现了无锁Hash,但是大小是限定的,而Map是大小不限定的)。
其次是避免锁的使用引起的错误和问题。
- 死锁(dead lock):两个以上线程互相等待
- 锁护送(lock convoy):多个同优先级的线程反复竞争同一个锁,抢占锁失败后强制上下文切换,引起性能下降
- 优先级反转(priority inversion):低优先级线程拥有锁时被中优先级的线程抢占,而高优先级的线程因为申请不到锁被阻塞
在现代的 CPU 处理器上,很多操作已经被设计为原子的,比如对齐读(Aligned Read)和对齐写(Aligned Write)等。Read-Modify-Write(RMW)操作的设计让执行更复杂的事务操作变成了原子操作,当有多个写入者想对相同的内存进行修改时,保证一次只执行一个操作。
RMW 操作在不同的 CPU 家族中是通过不同的方式来支持的:
- x86/64 和 Itanium 架构通过 Compare-And-Swap (CAS) 方式来实现
- PowerPC、MIPS 和 ARM 架构通过 Load-Link/Store-Conditional (LL/SC) 方式来实现
开发无锁算法感触最深的是复杂度的分解,比如多线程对于一个双向链表的插入或删除操作,如何能一步一步分解成一个一个串联的原子操作,并能保证事务内存的一致性。