Shunlqing's Blog

触动、反思,然后重新出发


  • 首页

  • 标签

  • 分类

  • 归档

  • 搜索

std::atomic

发表于 2018-07-29 | 分类于 编程语言

在单生产者单消费者的环形队列缓冲模型中,消费线程改变readPos_,生产线程改变writePos_,两者不互相干扰,但是我们仍然需要保证readPos_和writePos_的原子性。一种选择是使用C++的std::atomic模板类型。但是这种做法对性能的影响是怎么样的呢?带着这个问题,我做了一下测试。

测试环境:

Ubuntu 16.04虚拟机
主存频率:1600Mhz

readPos和writePos分别是内置类型size_t的时候

对一个std::size_t类型进行1亿次的++操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <time.h>
#include <stdio.h>

unsigned long long time_ns()
{
struct timespec ts;
clock_gettime(CLOCK_REALTIME, &ts);
return ts.tv_nsec + ts.tv_sec*1000000000LLU;
}

int main()
{
std::size_t a;
const long long unsigned TESTTIMES = 100000000;
long long unsigned start_ns = time_ns();
for(long long unsigned i = 0; i < TESTTIMES; i++)
{
a++;
}
long long unsigned delta = time_ns()-start_ns;
printf("%llu times tests use %llu ns(%.4f ns each)\n", TESTTIMES, delta, (delta)/(float)TESTTIMES);
}

执行结果:

100000000 times tests use 229754536 ns(2.2975 ns each)

readPos和writePos是std::atomic类型

在没有竞争的情况下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include <atomic>
#include <time.h>
#include <stdio.h>

unsigned long long time_ns()
{
struct timespec ts;
clock_gettime(CLOCK_REALTIME, &ts);
return ts.tv_nsec + ts.tv_sec*1000000000LLU;
}

int main()
{
std::atomic<std::size_t> a;

std::cout << std::boolalpha
<< "std::atomic<std::size_t> is lock_free? "
<< std::atomic<std::size_t>().is_lock_free() << endl;

const long long unsigned TESTTIMES = 100000000;
long long unsigned start_ns = time_ns();
for(long long unsigned i = 0; i < TESTTIMES; i++)
{
a++;
}
long long unsigned delta = time_ns()-start_ns;
printf("%llu times tests use %llu ns(%.4f ns each)\n", TESTTIMES, delta, (delta)/(float)TESTTIMES);
}

执行结果:

std::atomic<std::size_t> is lock_free? true
100000000 times tests use 985817387 ns(9.8582 ns each)

现在我们开启另外一个线程,两个线程同时对a进行++操作,各执行1亿次,使其有竞争出现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36

void *threadFun(void* p)
{
std::atomic<long long unsigned>& a = *(static_cast<atomic<long long unsigned>*>(p));
const long long unsigned TESTTIMES = 100000000;
long long unsigned start_ns = time_ns();
for(long long unsigned i = 0; i < TESTTIMES; i++)
{
a++;
}
long long unsigned delta = time_ns()-start_ns;
printf("%llu times tests use %llu ns(%.4f ns each)\n", TESTTIMES, delta, (delta)/(float)TESTTIMES);
}

int main()
{
std::atomic<std::size_t> a;
a = 0;
pthread_t t;
pthread_create(&t, NULL, threadFun, (void*)&a);

std::cout << std::boolalpha
<< "std::atomic<std::size_t> is lock_free? "
<< std::atomic<std::size_t>().is_lock_free() << endl;

const long long unsigned TESTTIMES = 100000000;
long long unsigned start_ns = time_ns();
for(long long unsigned i = 0; i < TESTTIMES; i++)
{
a++;
}
long long unsigned delta = time_ns()-start_ns;
printf("%llu times tests use %llu ns(%.4f ns each)\n", TESTTIMES, delta, (delta)/(float)TESTTIMES);
pthread_join(t, NULL);
cout << a << endl; //输出最终的结果
}

执行结果:

std::atomic<std::size_t> is lock_free? true
100000000 times tests use 1695992960 ns(16.9599 ns each)
100000000 times tests use 1859780698 ns(18.5978 ns each)
200000000

atomic模板类型不是lock_free

我们定义一个Test类,该类型如果作为atomic的模板参数,则不是lock_free。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
struct Test {
int b[100];
};

void *threadFun(void* p)
{
std::atomic<Test>& a = *(static_cast<atomic<Test>*>(p));
const long long unsigned TESTTIMES = 100000000;
long long unsigned start_ns = time_ns();
for(long long unsigned i = 0; i < TESTTIMES; i++)
{
((Test)a).b[0]++;
}
long long unsigned delta = time_ns()-start_ns;
printf("%llu times tests use %llu ns(%.4f ns each)\n", TESTTIMES, delta, (delta)/(float)TESTTIMES);
}

int main()
{
std::atomic<Test> a;
((Test)a).b[0] = 0;
pthread_t t;
pthread_create(&t, NULL, threadFun, (void*)&a);

std::cout << std::boolalpha
<< "std::atomic<Test> is lock_free? "
<< std::atomic<Test>().is_lock_free() << endl;

const long long unsigned TESTTIMES = 100000000;
long long unsigned start_ns = time_ns();
for(long long unsigned i = 0; i < TESTTIMES; i++)
{
((Test)a).b[0]++;
}
long long unsigned delta = time_ns()-start_ns;
printf("%llu times tests use %llu ns(%.4f ns each)\n", TESTTIMES, delta, (delta)/(float)TESTTIMES);
pthread_join(t, NULL);
cout << ((Test)a).b[0] << endl;
}

执行结果:

std::atomic<Test> is lock_free? false
100000000 times tests use 33430659148 ns(334.3066 ns each)
100000000 times tests use 33619378185 ns(336.1938 ns each)
1615295328

可见,使用atomic封装原子对象,针对不同的类型,其底层手段是不一样的,开销也就不一样。而无锁(lock_free)的底层实现比加锁保证原子性的底层实现,开销差异非常大,多大几十倍。

回到最开始的问题,如果使用的std::size_t类型,是无锁的。

new和malloc

发表于 2018-07-29 | 分类于 编程语言

刚学习new和delete的时候,我们就被忠告:

new和delete,new[]和delete[]要配对使用,不能混淆使用。

具体是为什么呢?我们都知道new和malloc都是用来分配内存的。那么他们都有什么区别呢?简单来说,malloc仅仅是分配指定大小的内存,而new不仅分配了内存,还在内存上面构造了对象。好比如,你去申请一块100平的地建房子,malloc就真的只是给你一块100平方的地,上面什么也没有;而new不仅给你100平方的地,而且按照你的意愿,已经在上面改好了房子,你直接入住就可以了。如果是new[]操作,就像是给你建好了一排别墅的感觉。

new和malloc

对于内置类型:

1
2
3
4
5
6
#include <stdlib.h>
int main()
{
int* p1 = (int*)malloc(sizeof int);
int* p2 = new int();
}

对于内置类型,malloc和new都是分配了指定大小的内存,然后简单初始化为0。内置类型没有构造函数,也不需要调用。

对于没有自定义构造函数的类型:

1
2
3
4
5
6
7
8
9
10
11
#include <stdlib.h>
class Test {
public:
int a;
};

int main()
{
Test* p1 = (Test*)malloc(sizeof(Test));
Test* p2 = new Test();
}

对于没有自定义构造函数的类型来说,malloc和new也是分配了指定类对象大小的内存,malloc简单初始化为0(可能有的甚至不做初始化),new也是这样么?我们为Test类增加一个虚函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <stdlib.h>
class Test {
public:
virtual void foo(){};
int a;
};

int main()
{
Test* p1 = (Test*)malloc(sizeof(Test));
Test* p2 = new Test();
// p1->foo(); 会发生Segmentation fault
p2->foo();
}

虽然没有自定义构造函数,但是还是看出差别,因为有虚函数的类对象需要有一个虚表指针指向虚表,用于在运行期动态获取虚函数的地址,new不仅分配了对象内存,而且并在对象内存的特定位置构造好了虚表指针,malloc根本不会这样做。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <stdlib.h>
#include <iostream>
class Test {
public:
Test() {
std::cout << "Test()" << std::endl;
}

int a;
};

int main()
{
Test* p1 = (Test*)malloc(sizeof(Test));
std::cout << "---------" << std::endl;
Test* p2 = new Test();
}

执行结果:

---------
Test()

可见,对于有自定义构造函数的类来说,new会调用自定义构造函数。

new[]和malloc

对于new[]和malloc分配内置类型或没有自定义构造函数类的对象数组,其差别基本跟上面所讲的差不多。

  1. 对于有自定义构造函数的类,new[]需要调用相应次数的构造函数。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    #include <stdlib.h>
    #include <iostream>
    class Test {
    public:
    Test() {
    std::cout << "Test()" << std::endl;
    }
    ~Test() {
    std::cout << "~Test()" << std::endl;
    }
    int a;
    };

    int main()
    {
    Test* p1 = (Test*)malloc(sizeof(Test)* 5);
    std::cout << "---------" << std::endl;
    Test* p2 = new Test[5]();
    }

    Test()
    Test()
    Test()
    Test()
    Test()

  2. 有自定义析构函数和没有自定义析构函数的区别

测试环境是:Ubuntu 16.04 64位

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <stdlib.h>
#include <iostream>
class Test {
public:
Test() {
std::cout << "Test()" << std::endl;
}
~Test() {
std::cout << "~Test()" << std::endl;
}
int a;
};

int main()
{
Test* p1 = (Test*)malloc(sizeof(Test)* 5);
std::cout << "---------" << std::endl;
Test* p2 = new Test[5]();
//delete p2; //用delete代替delete[], 产生coredump
delete (char*)p2 - 8; //不会core dump,但是不会执行析构函数。
delete p1;
}

使用valgrind检测,发现,delete ((char*)p2 - 8)不会出现内存泄漏,也就是说已经正常回收。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 没有自定义析构函数的情况下
#include <stdlib.h>
#include <iostream>
class Test {
public:
Test() {
std::cout << "Test()" << std::endl;
}
// ~Test() {
// std::cout << "~Test()" << std::endl;
// }
int a;
};

int main()
{
Test* p1 = (Test*)malloc(sizeof(Test)* 5);
std::cout << "---------" << std::endl;
Test* p2 = new Test[5]();
delete p2; //不会产生core dump
// delete (char*)p2 - 8; //会core dump
}

我们看下,在有自定义析构函数的情况下,多出来的8字节(64位机)是干嘛的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <stdlib.h>
#include <iostream>
class Test {
public:
Test() {
std::cout << "Test()" << std::endl;
}
~Test() {
std::cout << "~Test()" << std::endl;
}
int a;
};

int main()
{
Test* p2 = new Test[5]();
std::cout << *(int*)((char*)p2-8) << std::endl;
delete[] p2;
}

Test()
Test()
Test()
Test()
Test()
5
~Test()
~Test()
~Test()
~Test()
~Test()

存储了数字刚好是数组的大小。由此我们推测,这不仅仅是记录数组存储对象的个数,因为个数本身没什么意义。更重要的是,要正确执行相应次数的析构函数,这也是为什么没有自定义构造函数就不需要记录这个数字,使用delete ptr-8(64位机)会引发core dump。

所以delete和delete[]最好不要混用。

C++11线程池

发表于 2018-05-24 | 分类于 编程语言

本文关注C++线程池的设计,以一个典型的设计方案为例,分析C++11线程池的设计方式。

设计方案

线程池希望达到的效果:

  • 提交任务的方式灵活,可以提交带任意参数的可调用对象。同时提交线程可以获取任务执行完成的状态。
  • 线程池伸缩性,自动回收空闲线程

设计思想

基本成员:工作线程队列(workers),任务队列(tasks),以及条件变量。

基本行为:每创建一个工作线程,都等待在任务队列上(获取条件变量)。任务被提交(commit),加入到任务队列后,通知工作线程,工作线程提取任务执行。

提交方式(CommitTask):为提交带任意参数的可调用对象,使用可变长参数的形式。该函数期望返回异步结果,使用std::future的返回类型,同时在提交之前封装成std::packaged_task的形式从而课获取到std::future类型的结果。

如何回收线程:设计一个监控线程,该线程主要任务是循环监控线程池的空闲线数,在发现空闲线程数超过设置的最大空闲线程数,就回收线程。回收方法很巧妙:向任务队列添加相应数量的“停止任务”,工作线程执行该任务后,会退出线程。

Commit行为

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
template <typename F, typename... Args>
auto ThreadPool::CommitTask(F&& f, Args&&... args) -> std::future<typename std::result_of<F (Args...)>::type>
{
using retType = typename std::result_of<F (Args...)>::type;

auto task = std::make_shared<std::packaged_task<retType ()>>(std::bind(std::forward<F>(f), std::forward<Args>(args)...));
{
std::unique_lock<std::mutex> guard(mutex_);
if(shutdown_)
{
return std::future<retType>();
}

task_.emplace_back( [=]() { (*task(); )}); // 因为任务队列以std::function<void ()>形式组织
{
_CreateWorker();
}

cond_.notify_one();
}

return task->get_future(); //获取std::future的异步结果
}

设计要点以及使用C++11特性

  • 使用模板参数F,以支持任意可调用对象类型的传入,比如lambda,std::function,函数指针,仿函数
  • 使用std::future返回任务的执行结果。
  • 使用std::packaged_task封装任务,以获取异步结果。
  • 使用std::forward,完美转发。
  • 使用emplace_back利用了右值引用的特性和完美转发。emplace_back将参数完美转发给vector的构造函数,也就是说,传入的是lambda,则将使用移动构造函数。

工作线程的执行函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
void   ThreadPool::_WorkerRoutine()
{
working_ = true;

while (working_)
{
std::function<void ()> task;

{
std::unique_lock<std::mutex> guard(mutex_);

++ waiters_;
cond_.wait(guard, [this]()->bool { return this->shutdown_ || !tasks_.empty(); } );//谓语,
//只有谓语的执行结果为false,再能继续进行
-- waiters_;

if (this->shutdown_ && tasks_.empty()) // shutdown主动执行的话,直接退出
return;

task = std::move(tasks_.front()); //获取一个任务
tasks_.pop_front();
}

task(); //执行任务
}

// if reach here, this thread is recycled by monitor thread
// 线程执行到这里,表示该线程执行了一个停止任务,相当于消耗了一个停止任务。
-- pendingStopSignal_;
}

监控线程的执行函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
void   ThreadPool::_MonitorRoutine()
{
while (!shutdown_)
{
std::this_thread::sleep_for(std::chrono::seconds(1));

std::unique_lock<std::mutex> guard(mutex_);
if (shutdown_)
return;

auto nw = waiters_;

nw -= pendingStopSignal_; //pendingStopSignal_表示当前任务队列中未被消费的“停止任务”。

while (nw -- > maxIdleThread_) //回收多余的线程
{
tasks_.push_back([this]() { working_ = false; });
cond_.notify_one();
++ pendingStopSignal_;
}
}

STL——迭代器

发表于 2018-05-21 | 分类于 编程语言

本文主要关注STL的迭代器,浅谈迭代器在STL中扮演什么样的角色,以及它是怎么起作用的。

1 没有迭代器的日子

刚学数据结构与算法的时候,我们写一个数组容器array,写一个链表容器list,如果要写一个查找算法find,我们的做法是:
为array写一个array_find,为list写一个list_find。当有更多不同的数据结构,我们就要为不同的数据结构都分别写一个
find算法。无比繁琐。

反观find算法,它无非是从容器的第一个节点开始,每次往前走一个节点,然后进行比对。关键的不同在于不同的容器每向前走
一步的行为方式是不同的,对数组是直接往前走一个元素的尺寸;对于链表,需要找到下一个节点的地址,然后跳转到那个地址。

怎么样才能让一个find,可以操作各种不同的容器呢?让算法只关心它自己的核心行为,也就是算法行为本身(find: 从第一个元素,比对一次,找下一个元素,再比对,再找下一个,直到找到或到达容器尾部)。而具体在容器上的行为,算法不关心。

2 引入迭代器

为了让算法和容器之间解耦,我们引入一个“中间人”角色。我们为每个容器设计一个中间人,这个中间人代表着容器,对外它们有着一样的接口,对内,不同容器的中间人是不一样的。承接上文,这个“中间人”就是让算法“驱使”的。算法让“中间人”往前走一步,它就走一步。但是具体是怎么前进的,中间人知道就可以了,算法不理会。而这个中间人,就是所谓的迭代器。

迭代器其实是23种设计模式中的一种,模式定义如下:

提供一种方法,使之能够依序巡访某个聚合物(容器)所含的各个元素,而又无需暴露该聚合物的内部表述方式。

另一方面,STL的中心思想在于:将数据容器和算法分开、彼此独立设计,最后再用迭代器联系在一起,完美!

3 作为一个代表,迭代器需要知道容器哪些属性

迭代器最终是被算法所使用的。因此,迭代器需要知道容器的哪些信息,完全是由算法决定的。

  • 最基本的,算法需要知道容器元素的类型T。
  • 算法可能需要获取容器元素类型的引用,获知其是否可以修改。迭代器要提供。
  • 算法可能需要获取容器元素类型的指针,迭代器要提供。
  • 算法需要在容器元素之间移动,所以迭代器需要提供在容器元素之间移动的方式。比如++,–,+n,-n等。
  • 等等。。。

STL设计者归纳起来,作为迭代器,需要记录容器的五种信息,这五个信息作为迭代器的属性:

  • value_type: 迭代器所指对象之类型,一般可以直接理解为容器元素的类型。
  • different_type: 两个迭代器之间的距离类型。
  • reference_type: 迭代器是const还是非const,表征是否可在相应容器元素上做修改。
  • pointer_type: 迭代器所指对象的指针类型。
  • iterator_category: 反映迭代器的移动特性和实施操作。比如最基本的++行为在不同容器的迭代器是不同的。(STL设计者在此做了很多巧妙的设计)
1
2
3
4
5
6
7
8
9
10
// std::iterator
template <class Category, class T, class Distance = ptrdiff_t,
class Pointer = T*, class Reference = T&>
struct iterator {
typedef Category iterator_category;
typedef T value_type;
typedef Distance difference_type;
typedef Pointer pointer;
typedef Reference reference;
};

此外,迭代器还要记录容器的操作方式和移动方式,这有点像智能指针。比如:

  • operator*
  • operator->
  • operator++
  • operator--

也就是说,如果一个独立设计的容器能够提供符合其特点、同时满足上述定义的迭代器,那么这个容器就能加入到STL算法的大家庭中。

4 迭代器设计的工作

针对特定的容器,设计迭代器所要做的工作就是分析并精确定义前面描述五种类型,以及各种操作,比如operator++,operator–,operator->,operator*等等。

比如,容器list的迭代器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
template <class T, class Ref, class Ptr>
struct list_iterator {
//五种迭代器的属性,使用内嵌类型的方式声明,用于萃取
typedef bidirectional_iterator_tag iterator_category;
typedef T value_type;
typedef Ptr pointer;
typedef Ref reference;
typedef ptrdiff_t difference_type;

// 各种移动方式和操作方式
bool operator==(const self& x) const { return node == x.node; } // 类似指针比较
bool operator!=(const self& x) const { return node != x.node; } //

reference operator*() const { return (*node).data; } //迭代器取值,取的是节点的值

pointer operator->() const { return &(operator*()); }

self& operator++() {} //前置++
self operator++(int n) {} //后置++
self& operator--() {} //前置--
self operator--(int n) {} //后置--
};

另外,因为原生指针T*满足vector迭代器该有的属性,所以vector的迭代器直接使用T*

1
2
3
4
5
template <class T, class Alloc = alloc>
class vector {
public:
typedef T* iterator;
};

5 算法怎么获取迭代器的属性

STL设计者使用了一种很巧妙的方法:内嵌型别 + iterator_traits萃取。

内嵌型别指的是:直接在迭代器类的内部声明五种属性的类型(见前面list)。

iterator_traits萃取即:

1
2
3
4
5
6
7
8
9
template <class Iterator>
struct iterator_traits
{
typedef typename Iterator::iterator_category iterator_category;
typedef typename Iterator::value_type value_type;
typedef typename Iterator::difference_type difference_type;
typedef typename Iterator::pointer pointer;
typedef typename Iterator::reference reference;
};

iterator_traits是个模板类,通过模板参数(迭代器的类型)Iterator,萃取出迭代器的五种属性。而算法根据萃取出来的五种属性类型,而选择不同的策略。

比如:根据萃取出来的距离类型声明返回值类型

1
2
3
4
5
6
template<class Iterator>
typename iterator_traits<Iterator>::difference_type
distance(Iterator first, Iterator last){
typedef typename iterator_traits<Iterator>::iterator_category iterator_category;
return _distance(first, last, iterator_category());
}

然而,问题来了,像vector这种直接把原生指针T*作为迭代器,并没有专门设计一个vector_iterator类,并在类内内嵌各种声明blablabla。怎么办?

答案:模板偏特化

比如,为原生指针而设计的traits偏特化版

1
2
3
4
5
6
7
8
9
template <class T>
struct iterator_traits<T*>
{
typedef random_access_iterator_tag iterator_category;
typedef T value_type;
typedef ptrdiff_t difference_type;
typedef T* pointer;
typedef T& reference;
};

我们也要为const T*设计偏特化版本。具体可参考侯捷《STL源码剖析》

讨论到此,我们对迭代器为何存在于STL中有了一定的认识。要深入认识,可以阅读源码。


本文完。

从几个维度认识Linux进程

发表于 2018-05-21 | 分类于 操作系统

本文关注Linux进程

Linux内核——等待队列浅谈

发表于 2018-05-19 | 分类于 操作系统

本文关注内容:Linux内核的一种数据结构:等待队列。在这里,我们不太去关注等待队列细节的实现。主要从功能角度谈下等待队列在内核中的角色。
我的观点是,不管是内核,还是内核中的某个组件,理解其角色定位很重要,单纯是从实现细节上去理解某种机制,或许少了点乐趣。

你理解阻塞吗?

写程序的时候,我们常常说某个系统调用是阻塞调用。从用户层的角度,基本理解是:进程在执行某个系统调用的时候,因为需要的资源不满足(IO操作,加锁等等),导致进程“停”在那里。等到资源就绪了或者设置的timeout时间超时了,进程得以继续执行。

从内核的角度,面对用户层对阻塞调用的需求,需要实现哪些机制呢?

  • 1.首先,进程陷入内核,内核发现进程所要求的资源暂时无法满足,需要将其设置为睡眠状态,然后调度其他进程执行。这里引出一个问题:(1)内核如何将一个进程睡眠?
  • 2.再来,等待资源就绪时,我们需要唤醒等待在该资源上面的进程。这里存在两个问题:(2)内核是怎么知道资源就绪的?以及,(3)某个资源就绪了,内核怎么找到对应等待的进程的?

示例:

我们希望对某个socket fd进行阻塞write操作。发起写操作的时候,陷入内核,内核发现该socket fd的写缓冲区是满的,暂时不能写。这时,内核将进程设置为睡眠。
调用其他进程执行。等到该写缓冲区可以写的时候,内核将进程设置为执行状态,然后执行写操作,拷贝数据到内核写缓冲区。执行完,切换回用户态。

问题一:内核如何将一个进程睡眠

进程的task_struct结构有一个状态成员。将其设置为“睡眠”,并将task_struct结构从就绪队列中移走,内核就不会调度其执行,也就相当于睡眠。

问题二:内核怎么知道资源就绪的

中断。内核的所有的工作都是由中断驱动的。不管是系统调用陷入内核,还是调度,还是其他的内核活动,都是由各种各样的中断来触发执行的。对于设备IO,如果设备空闲了,会触发一个外部中断,该中断触发内核执行处理程序,通知等待进程、执行回调等等。

问题三:资源就绪了,内核怎么找到对应等待的进程

答案是等待队列。我们将一个资源和一个等待队列关联起来。如果进程所请求的资源还未就绪,就先加入到该资源的等待队列中。等到资源就绪了,就唤醒等待队列中的进程,加入到调度。

何为等待队列

等待队列就是一个普通的双向链表,该链表的每个节点都代表一个进程task_struct的封装。每个资源都会有相应的等待队列。

等待队列与“惊群”

“惊群”的基本行为是:有多个进程或者线程等待在同一个资源上,而且该资源一次只能有一个进程处理,比如文件描述符的写操作,accept一个新连接等。那么在资源就绪的时候。如果内核采取的策略是唤醒所有的进程。这样,只有一个进程获取了该资源,其他进程发现没有资源就绪,继续进入睡眠(所谓虚假唤醒)。这样的行为浪费了系统的CPU资源。

那是不是,内核在资源就绪的时候,就唤醒一个进程不就得了。其实也不是,因为不是所有资源都是互斥的。比如,某个文件的读操作。

那么,惊群问题怎么解决?

在用户态,可以有不同的解决方式。或者忽略惊群所带来的开销,或者使用锁方式保证一次只有一个进程来阻塞在一个资源上。而对于内核来说,在等待队列上增加了一个是否“互斥等待”的标志。

如果是互斥等待的,一次唤醒一个进程。如果不是互斥等待的,一次唤醒所有进程。

互斥等待的经典例子:accept。因为我们很明确知道,对一个listen fd的accept,肯定是一次只有一个进程可以处理。那么,我们在listen fd上的等待队列,就毫无疑问可以设置为“互斥等待”。所以,现今版本的linux内核,解决了accept的惊群问题。

但是像epoll_wait的惊群问题,就无法从等待队列的互斥等待来解决。首先,epoll fd上也有一个等待队列,代表epoll fd所监听的其他若干文件描述符(资源)就绪时,唤醒等待队列上的资源。因为我们无法确定,这些资源是不是都是互斥访问的,还是都不是。所以,只好唤醒所有进程。更多的惊群问题,可以查阅相关资料。

总结

等待队列是内核实现进程阻塞调用的机制。我们从概念上,浅谈了等待队列的用途,以及等待队列会出现的惊群问题。有说得不清楚的地方,还请见谅。

进程环境

发表于 2018-05-18 | 分类于 操作系统 , 技术笔记

讨论进程环境中几个点:

  • 进程终止:exit和_exit的关系
  • 环境变量的设置,putenv的陷阱
  • 内存问题
  • longjmp的原理与陷阱

exit和_exit

当一个进程正常终止时,系统需要做一系列的处理。这里涉及到两个层面:C库和内核的处理:

  • C库层做的工作:刷新IO缓冲区,执行所有注册的退出函数;
  • 内核层做的工作:回收进程所持有的一些资源;

进程正常退出时,先执行exit函数(C库层面),再在exit函数里面调用_exit陷入内核,执行内核退出进程的操作。但异常退出又是怎么样的呢?异常退出只会执行内核退出进程的操作。

正常退出:

  • main使用return;return(n)相当于exit(n);
  • 直接使用exit(n)

异常退出:

  • 使用_exit(n),因为没有正常执行C库层面的清理工作,所以我们把它归纳到异常状况。但是有时候_exit是必要的,比如多进程的时候。
  • 信号中断。当出现信号中断,信号中断很多的处理都是进程终止,此时执行的直接是内核退出进程的操作,不会返回到C库执行清理工作。

exit

1
void exit(int status);

exit()执行的动作:

  • 调用注册的退出处理程序(通过atexit()和on_exit()注册的函数),其执行顺序与注册顺序相反(函数栈)
  • 刷新stdio流缓冲区
  • 使用由status提供的值执行_exit()系统调用。

_exit()

1
void _exit(int status);

_exit()是系统调用,即其直接陷入内核,执行内核退出进程的操作(该操作无论是进程正常终止还是异常终止都会执行)

内核退出进程的操作

  • 关闭文件描述符(释放任何文件锁),目录流
  • 分离共享内存段
  • 信号量
  • 取消该进程调用mmap所创建的任何内存映射
  • 等等。。。

使用atexit注册退出处理程序

环境变量

1
2
int putenv(char *string);
int setenv(const char * name, const char *value, int overwrite);

关键注意点:

  • putenv直接使用的是string的内存空间,即需要保证string指向的变量长期存在,全局变量或动态内存等;setenv不存在这个问题,它会做相应的拷贝。
  • putenv参数形式,string的格式是“名字=值”;setenv参数分开

内存问题

慎用realloc

防止内存越界的几个新旧函数

使用strncat替代strcat

1
char *strncat(char *dest, const char * src, size_t n);

从src中最多追加n个字符到dest字符串的后面。该函数自动追加‘\0’到dest的后面,所以n应该为dest可用空间减1。

使用strncpy代替strcpy

1
char *strncpy(char *dest, const char *src, size_t n);

从src中最多复制n个字符到dest字符串中。需要预留‘\0’的一个字节,并手动添加‘\0’。

使用snprintf代替sprintf

1
2
3
4
5
6
7
8
    int snprintf(char *src, size_t size, const char* format, ...);
```c
包含‘\0’,最多复制size个字符。

**使用fgets代替gets**
```c
char *gets(char *str);
char *fgets(char *s, int size, FILE *stream);

gets函数从来不检查缓冲区的大小。fgets最多会复制size-1字节到缓冲区s中,并且会在最后一个字符后面自动追加‘\0’。

如何定位内存问题——Valgrind

常见内存问题:

  • 动态内存泄露:malloc,free
  • 资源泄露,如文件描述符
  • 动态内存越界
  • 数组越界
  • 动态内存double free
  • 使用野指针

“长跳转”longjmp

goto是在函数内部实现短跳转,要实现跨函数跳转,得使用长跳转longjmp。

longjmp的原理

和进程切换、信号中断返回的思想类似,要想实现非正常执行流的跳转,就需要进程在某一时刻的上下文。只要有进程上下文,将其装填到寄存器中,就能够实现跳转。

1
2
int setjmp(jmp_buf env);
void longjmp(jmp_buf env, int val);

  • setjmp使用jmp_buf结构体保存调用时刻的进程上下文,此结构变量需保证在longjmp时时存在的。
  • setjmp返回0时,为直接返回结果;返回非0值,为longjmp恢复栈空间返回的结果。
  • 跳转一次之后,env保存的上下文就失效了。

longjmp的陷阱

长跳转的实现原理是对与栈相关的寄存器的保存和恢复。

全局变量和static变量

保存在静态存储区,所以longjmp之后不会改变,不影响;

局部变量

满足一下条件的局部变量的值是不不确定的

  • 调用setjmp所在函数的局部变量
  • 其值在setjmp和longjmp之间有变化
  • 没有被声明为volatile变量

C++析构函数

调用longjmp之后,没有正常解栈,本该调用的析构函数没有调用。如:

1
2
3
4
5
6
7
8
void func()
{
Test test;
longjmp(g_stack_env);
}
/*
对象test的析构函数没有调用。
*/

linux文件IO

发表于 2018-05-18 | 分类于 操作系统 , 技术笔记

文件描述符和句柄

句柄,从一般意义上讲,句柄作为内核与用户的交互。用户无法指定内核对象的本体,内核可以提供一个整数值,在内核创建内核对象的时候,返回给用户。用户在想调用该对象的时候,可以指定。

例如,文件在内核的活动对象是一个个file结构,对于用户来说,文件描述符代表这一个文件的标识。用户可以指定文件描述符,内核通过查找来找到文件管理结构file.

因此,文件描述符可以称为“文件句柄”。

内核文件表

每个进程管理一张打开文件表,用以管理当前进程所打开的文件,称为“进程打开文件表”,保存在task_struct->files字段,是一个files_struct结构体。

files_struct结构的功能是管理一个file结构指针数组,文件描述符就是用来索引该数组,从而找到真正的文件管理结构file.此外,files_struct的两个重要字段:

  • close_on_exec_init: 表示当执行exec时要关闭的文件描述符位图
  • open_fds_init: 保存打开的文件描述符位图

文件管理结构file

1
2
3
4
5
6
7
8
9
struct file {
/*...*/
const struct file_operations *f_op; 与该文件相关联的操作函数
atomic_t f_count; 文件的引用计数(有多少进程打开该文件)
unsigned int f_flags; 对应于open时指定的flag
mode_t f_mode; 读写模式:open的mod_t mode参数
off_t f_pos; 该文件在当前进程中的文件偏移量
/*...*/
};

文件操作

open操作

打开一个文件,内核都做了些什么呢?

  • 在进程打开文件表中找到一个未使用的文件描述符
  • 申请一个新的文件管理结构file,根据不同的打开标志和mode产生不同的行为和file结构。
  • 绑定文件描述符和对应的文件管理结构file,把文件描述符返回给用户。

可见,open操作,内核主要消耗两种资源:文件描述符和文件管理结构file。

open参数

  • pathname : 文件路径
  • flags: 打开标志
  • mode:文件的权限位,只在创建文件时需要,并受到umask的影响。

每一次open操作,都将分配一个文件描述符和文件管理结构,即使打开的是同一个文件.

close操作

close用于关闭文件描述符。而文件描述符可以是普通文件,也可以是设备,还可以是socket.在关闭时,VFS会根据不同的文件类型,执行不同的操作。这个实现机制由file结构的fop字段绑定的具体操作函数实现。

遗忘close造成的问题

  • 文件描述符没有释放
  • 文件管理结构没有释放
  • 对于普通进程,在进程结束时,内核自动回收;但是对于常驻进程,问题相当严重。

如何查找文件资源泄露——lsof命令

文件偏移

文件偏移量是打开文件中比较重要的属性.它代表当前进程对当前打开文件的读写位置.一般情况下,读写操作都是从当前的偏移位置开始读写, 并且在读写结束之后更新偏移量.

刚打开文件时, 文件偏移量为0;

进程fork之后,父子进程的对同一打开文件的文件偏移量是一样的吗?后面解答.

读文件_read

1
ssize_t read(int fd, void* buf, size_t count);

最一般意义,read尝试从fd中读取count个字节到用户定义缓冲区buf中,并返回实际成功读取的字节数.

意外情况:返回值为-1

  • errno = EAGAIN, EWOULDBLOCK: fd为非阻塞且没有数据可返回时
  • errno = EINTR: 信号中断

部分读取

不同类型的文件出现部分读取的情况是不同的,根据具体绑定的fops->read函数而定:

  • 普通文件,到达文件末尾 EOF
  • socket文件系统的UDP: UDP报文数据长度小于参数len时,返回实际的数据长度
  • …(根据不同的类型小心处理)

写文件_write

1
ssize_t write(int fd, const void*buf, size_t count);

write操作根据当前fd的文件偏移量, 写入count个字节数据.与read操作类似,同样会出现部分写的情况.

追加写–O_APPEND

当使用O_APPEND以追加的形式打开文件的时候,每次写操作都会先定位到文件的末尾,然后再执行写操作.每次写操作获取到的都是文件(inode)的最新末尾位置(对inode加锁保护, 以避免多进程情况下竞争).

文件描述符的复制

1
2
3
int dup(int oldfd);
int dup2(int oldfd, int newfd);
int dup3(int oldfd, int newfd, int flags);
  • dup:使用一个最小的未使用的文件描述符作为复制后的文件描述符
  • dup2:使用用户指定的newfd来复制oldfd,如果newfd已经打开,就先关闭newfd,在复制oldfd(close+dup的原子性调用)
  • dup3:支持O_CLOEXEC.表示新复制的fd在fork并执行exec之后,将关闭.

复制的只是文件描述符

新复制的文件描述符和旧的文件描述符指向的是同一个file文件管理结构,他们共享文件偏移量,文件读写模式,文件打开标志等等. file的创建仅仅在open操作的时候发生.

文件同步

文件截断

进程执行fork后,文件打开表和文件管理结构file

管道——花园软水管

发表于 2018-05-18

一、小谈管道历史

关于管道,有这么一句话:

如果说Unix是计算机文明中最伟大的发明,那么,Unix下的Pipe管道就是跟随Unix所带来的另
一个伟大的发明。

管道的出现就是为了使软件开发更加“高内聚,低耦合”。管道的发明者,Malcolm Douglas McIlroy,同时也是Unix创建者及Unix文化缔造者,他的Unix哲学:

程序应该只关注一个目标,并尽可能把它做好。让程序能够互相协同工作。应该让程序处理文本
数据流,因为这是一个通用的接口。

也就是说,管道存在的意义就是让程序能够专注自己的目标,而不同进程之间可以相互通信协作,完成一个更大的目标。比如:在主机上,客户进程可以通过管道给文件服务器进程发送文件名,文件服务器打开文件然后将文件数据通过管道传给客户进程,如此,客户进程和文件服务器相互独立,同时相互协作。这其实不仅仅是管道的特点,同时也是其他进程间通信(IPC)手段的特点。不同的IPC实现各有不同,但是目的、哲学道理都是差不多的。

IPC技术:管道,共享内存,消息队列,信号量,本地套接字等。

管道一般是单向的,半双工的,像花园的软水管,一边进一边出。要想实现全双工,一般需要两条管道。

二、管道基本实现

每个进程都有自己的独立的地址空间,要想实现进程之间的相互通信,必须采取必要的手段: 共享内存或者借助内核。管道的实现就是借助内核。

管道的本质就是内核维护的一段内存。因为linux“一切皆文件”的思想,管道自然而然就被实现为“管道文件”(向普通文件一样管理),隶属管道文件系统pipefs。因此,和普通文件一样,内核负责维护文件的细节,返回给用户进程的只是一个个“文件描述符”,通过文件描述符,进程可以执行打开管道、读写管道的操作。

管道分为两种:无名管道pipe和有名管道FIFO

无名管道pipe

何为无名管道?无名管道就是用户进程只能通过文件描述符fd才能找到的管道,内核没有给其他方式告诉管道在哪里。即没有名字,只有句柄(文件描述符)。一般来讲,文件描述符只会在有亲缘关系的进程间继承,这就限制了无名管道一般只用在有亲缘关系进程间通信。

有名管道FIFO

何为有名管道?有名管道,对比无名管道,它跟实体文件名绑定。任何进程只要知道跟管道绑定的文件名,就可以尝试打开管道并操作。这意味着,有名管道可以使用在没有亲缘关系的进程间通信使用。

三、无名管道pipe

创建无名管道pipe

1
2
3
4
5
6
7
8
//原型
int pipe(int fd[2]);

//例子

int pipefd[2];
int error = pipe(pipefd); //调用成功,返回两个文件描述符,
管道只读端pipefd[0]和管道只写端pipefd[1];

无名管道使用(搭配进程fork)

无名管道因其只以文件描述符索引,所以一般的使用方式:

某个祖先进程(父进程)先创建管道,然后fork出若干子进程,父子进程或兄弟进程之间通过继承得到
的管道文件描述符来读写管道,从而达到通信的目的。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
//Example:

int pipefd[2];
pid_t childpid;

pipe(pipefd);

if((childpid = fork()) == 0) {
//子进程
//关闭管道读端或写端描述符
//write or read

exit(0);
} else if(childpid > 0){
//父进程
//关闭管道写端或读端描述符
// read or write

// waitpid()
} else {
//fork error
}

注意点 :管道的双方要关闭不需要使用到的文件描述符,为什么?这涉及管道读取端如何判断对方已经不再写了?管道写端进程如何判断管道另一头已经没有进程在读了?

  • 当读取端已无进程等待(即fd[0]的引用计数为0 (close)), 此时若有进程对写端继续写,返回EPIPE错误。并产生信号SIGPIPE。
  • 当写端已无进程再继续写(即fd[1]的引用计数为0 (close)), 若管道的数据读完,将返回文件结束符EOF。

因此,为了能够正常判断结束条件,进程要关闭其没有使用的管道文件描述符。

无名管道是不是只能用在用亲缘关系的进程中?

答案是否定的。因为借助本地套接字,可以在进程间传递文件描述符。

popen/pclose

1
2
3
#include <stdio.h>
FILE *popen(const char* command, const char *type);
int pclose(FILE *stream);

popen行为:fork并执行shell进程,shell进程fork并执行command进程,并返回文件描述符。该文件描述符fd或为读端(管道写端关联到command进程的标准输出),或为写端(管道读端关联到command进程的标准输入)。这取决与popen是以读模式还是以写模式打开。

四、命名管道FIFO

创建管道

1
2
3
int mkfifo(const char* path, mode_t mode);
// 成功,创建一个新的FIFO
// 返回EEXIST错误(指定文件名的FIFO已经存在)

参数:

  • path: 文件路径名
  • mode: 文件权限位

创建管道用mkfifo,打开管道用open

调用mkfifo成功创建管道后,其他进程可以通过open相应的“文件名”从而获取管道,进行读或写。

五、管道属性

管道的打开行为、读写行为

管道open和O_NONBLOCK

管道默认是阻塞的。和普通文件描述符一样,任何时候可以通过fcntl(瑞士军刀)设置成非阻塞。

  • 管道阻塞, open + O_RDONLY : 此时没有进程为写打开,则阻塞。
  • 管道阻塞, open + O_WRONLY :此时没有进程为读打开,则阻塞。
  • 管道非阻塞, open + O_RDONLY : 无论有没有进程为写打开,立即返回。
  • 管道非阻塞, open + O_WRONLY : 没有读进程,则返回-1,errno = ENXIO。

管道读写

  • 阻塞、空管道或FIFO,read:有写进程,阻塞到有数据;无写进程,返回EOF;
  • 非阻塞、空管道或FIFO,read: 有写进程,返回EAGAIN;无写进程,返回EOF;
  • 阻塞、管道或FIFO,write: 有读进程,见下文;无读进程,返回SIGPIPE;
  • 非阻塞、管道或FIFO,write: 有读进程,见下文;无读进程,返回SIGPIPE;

管道写行为、PIPEBUF、原子性

管道有一特点,尽力保证写入的原子性:

如果请求写入的数据字节数小于等于PIPE_BUF,那么write操作保证是原子的。大于PIPE_BUF,则不保证。

如此,在非阻塞情况下:

  • 请求写入字节数小于等于PIPE_BUF:
    • 当前管道空间足够,则直接写入;
    • 当前管道空间不够,为保证原子性,先返回EAGAIN,告诉进程稍后尝试;
  • 请求写入字节数大于PIPE_BUF:
    • 当前管道空间至少还有1字节,直接写相应字节数,返回;
    • 当前管道满,返回EAGAIN。

PIPE_BUF的大小可以通过fcntl来设置。

从上面的讨论可以看出,管道的读写需要一些技巧。所谓“管道有大小,写入需谨慎”。

  • 一次写入数据量不超过PIPE_BUF,以保证写入是原子的。即使存在多个进程同时读,也不会被打断。
  • 写端不要大量输入,会造成阻塞。
  • 读端,要及时读取,避免造成写入阻塞。

inline函数

发表于 2018-05-18 | 分类于 编程语言 , 技术笔记

引子程序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// file.cc
inline void foo(void)
{
/*函数体*/
}

// file.h
void foo(void);

// main.cc
int main()
{
foo();
}

上面的编码会出现什么问题? 链接错误,显示foo未定义(undefined reference to `foo()’).

编译器对inline函数的处理

使用inline函数,意图是在函数调用处直接替换成函数体,省去函数调用,提高函数的执行效率.但编译器在处理的时候,真正的处理流程是什么样的呢?

我们知道,c/c++程序编译分为几个阶段,预处理->编译+汇编->链接.inline的处理阶段发生在编译阶段.也就是说,在编译阶段完成后,目标模块的inline函数要么被替换了,要么没有被替换,但是链接时可以找到inline函数的定义.也就是说,在编译阶段之前,目标模块需要看到inline函数的实现.

拿引子程序来说,编译main.o目标模块时,编译器”看到”了foo的声明,此时它并不知道foo是要inline的.编译file.o目标模块时,编译器看到foo的实现,并且声明为inline,由于foo在file.o没有被使用到,所以,inline函数不被保留.链接的时候,当然也就找不到foo的定义实体了.针对此例子,我们可以在file.c定义一处函数,使用了一次foo(),编译就可以通过.

1
2
3
4
5
//file.cc
int foo2()
{
foo();
}

正确的做法是,inline函数的实现(定义)要跟随其函数声明放在.h文件中.编译器在编译阶段就可以看见函数实现,从而进行替换.(这一点有点向函数模板,函数模板也是要放在声明文件中,不能单独放在源文件中)

inline和宏的区别

  • 1.宏define在预处理阶段完成;inline在编译阶段完成;
  • 2.类型安全检查,inline是函数,需要做类型检查;
  • 3.替换方式:宏是字符串拷贝替换,会出现边际效应;inline是嵌入代码,在编译过程中不单独产生代码。
  • 4.宏不可调试,inline函数可以调试。这里的“调试”之意是指在程序的debug版本是没有内联的,编译器会生成像普通函数一样含有调试信息的可执行代码。在release版本,才实现真正的内联。
  • 5.inline函数可以操作私有数据成员。

内联函数的使用

inline函数的编程风格

关键字inline必须与函数定义放在一起才能使函数真正内联,仅把inlilne放在函数声明的前面不起任何作用.

1
2
3
4
5
6
7
8
9
10
11
12
13
//Foo不能内联
inline void Foo(int x, int y);
void Foo(int x, int y)
{
...
}

//Foo内联
void Foo(int x, int y);
inline Foo(int x, int y)
{
...
}

inline是一种”用于实现的关键字”,而不是一种”用于声明的关键字”.

慎用内联

  • 如果函数体内的代码过长,使用内联将导致可执行代码膨胀过大
  • 如果函数体内的代码出现循环或者其他复杂的控制结构,那么执行函数体内代码的时间比函数调用的开销大得多,因此内联的意义不大.

不要轻易让构造函数和析构函数成为内联函数

inline仅仅是对编译器的一种请求,编译器可以无视

inline常跟static一起

当多个源文件包含同一个inline函数,但是编译器又无视inline请求,如此一来,在每个目标模块中都有该函数的实现实例,造成重定义错误。把inline函数同时声明为static函数(限制其作用域为源文件内部),可以解决问题。

123
shunlqing

shunlqing

22 日志
7 分类
12 标签
E-Mail
© 2017 — 2019 shunlqing
本站访问量: 次 丨 本站访客: 人